Compare commits

...

5 commits

Author SHA1 Message Date
BruebachL
add65caae5
[Client] Hand off single-instance payloads outside the readyRead signal (#7373)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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
* [Client] Hand off single-instance payloads outside the readyRead signal

Opening a cockatrice:// link from outside a running client showed the
"Join game ...?" confirmation and then crashed on both answers. The
join reached the server first, so the other players saw the user join
and immediately disconnect.

SingleInstanceManager::handleNewConnection() emitted filesReceived from
inside the forwarding socket's readyRead lambda and only disconnected
afterwards. A joingame link makes the handler show a modal QMessageBox,
which runs a nested event loop. The sending instance exits as soon as
it has the acknowledgment, so the socket was disconnected and
deleteLater'd inside that loop: it died in the middle of its own
readyRead signal, and both this lambda and Qt's signal dispatch kept
using the freed memory.

Disconnect right after the acknowledgment and emit filesReceived
through a queued call, so no handler and no nested event loop ever runs
inside the socket's signal. Guarding the disconnect call alone is not
enough, since Qt still touches the socket after the lambda returns.

Add a regression test that reproduces the nested-event-loop hand-off.

* [Tests] Apply cmake-format to the single-instance test wiring

* [Client] Defer the single-instance socket teardown past the readyRead frame

The forwarded payload is emitted synchronously from inside the forwarding
socket's readyRead, which is what lets the modal "Join game?" confirmation
appear. A handler can spin a nested event loop, and the sending instance
exits as soon as it has the ACK, so the socket is disconnected and
deleteLater'd while that loop runs. Control then returns to this frame with
the socket already destroyed, and the inline disconnectFromServer() touched
freed memory.

Queue the teardown instead and guard it with a QPointer taken before the
handlers run -- building the guard afterwards is itself a use-after-free,
since QPointer dereferences the pointer it is constructed from. The payload
still reaches the handlers synchronously, so the confirmation is unchanged.

The regression test covers both halves: the handlers must run while the
socket is still connected, and the socket must not be torn down until the
readyRead frame has returned.

* Lint.

* [Client] Only delete the single-instance socket outside the readyRead frame

The auto-delete on `disconnected` was the remaining use-after-free. A
handler may spin a nested event loop (the modal "Join game?" box), the
sending instance exits as soon as it has the ACK, and so the peer closes
while that loop runs. deleteLater() issued from inside such a loop is not
deferred to the outer loop: Qt frees the object as soon as the nested loop
unwinds, which is still inside this readyRead emission, so Qt's own signal
dispatch and the handler lambda both keep using a freed socket. When the
notification happens to arrive late the bug hides, which is why this only
reproduced on some platforms.

Drop the auto-delete and let the queued teardown own the socket's lifetime:
it disconnects and then deletes, once the frame has unwound. The QPointer
guard was necessary but not sufficient -- it stopped us dereferencing a
dead pointer, not the socket being freed underneath the frame.

The test waits on the socket's destroyed signal instead of polling, so the
teardown assertion does not depend on platform event timing.

* [Client] Do not queue the single-instance socket teardown

The previous commit moved the deleteLater() off the disconnected signal
and into a queued invocation, believing that "queued" meant "after the
readyRead frame returns". It does not. A queued call is delivered by
whichever event loop is running, and the modal "Join game?" box runs a
nested one -- so the teardown was still executed inside the readyRead
emission, which is exactly the use-after-free it claimed to prevent.

Queueing was never necessary. Because nothing connects deleted/close to
the socket's disconnected signal any more, no nested loop can free it
while the handlers run, and emit filesReceived() is synchronous: once it
returns, the frame has unwound and the socket can be closed and deleted
inline. The QPointer stays, covering the one case that remains -- the
primary shutting down while the confirmation box is open, since
QLocalServer destroys its pending connections.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 21:19:02 +02:00
BruebachL
4ce2e8c3d3
[UserList] Fix join/spectate from the profile hover card doing nothing (#7383)
The games list in the profile hover card offered "Join game" and
"Spectate", but UserInfoPopup::joinGameRequested was only forwarded to an
identical UserListWidget::joinGameRequested signal that nothing connected
to. Clicking either entry sent no command, opened no tab and showed no
error.

Route the request to TabSupervisor, which owns the join flow. The popup's
games list spans every room the user is in, so the target room may not be
open yet; IntentJoinServerGame already joins the room first and retries,
which is why this goes through the intent rather than straight to
GameSelector.

Extract the intent construction that joinReportGame already had into a
shared joinGameByIntent helper instead of duplicating it.

Also stop disabling "Join game" for games that are full or already
running. GameSelector::joinGame() offers to join as a spectator in that
case, so a greyed-out entry read as a dead button; GameSelector's own
context menu never disables it either.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 18:57:07 +02:00
BruebachL
750d5b3c21
[Client] Add option to use game time in game log timestamps (#7265)
* [Client] Add option to use game time in game log timestamps (#2202)

Game log timestamps showed the local clock time, which is meaningless in
a replay or in a log read after the fact — nobody can tell when a play
happened relative to the game.

Add a "Use game time instead of local time in game logs" setting. When
enabled, the message log stamps entries with the elapsed game time
(HH:MM:SS since the game started); regular chat views keep the local
clock.

* [Game] Move formatElapsedTime helper out of public slots (#2202)

The message log needs the HH:MM:SS formatter used by the game timer, but
it lives in GameState's public slots, tying a pure formatting helper to
the signal-slot machinery.

Move formatElapsedTime into a public section of GameState so the log can
call it directly, and add the missing trailing newline to game_state.cpp.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 17:30:48 +02:00
RickyRister
ffcb9094ca
[Replay] Fix cards accumulating in zone view when rewinding (#7357)
* [Replay] Fix cards accumulating in zone view when rewinding

* rename method
2026-10-02 17:17:34 +02:00
BruebachL
ecd955bc60
[Installer] Wait on the launching process so auto-updates cannot write over a mixed Qt runtime (#7372)
* [Installer] Abort instead of installing over leftover runtime files

The silent upgrade could leave a mix of old and new Qt DLLs next to the new
executables, which is what makes the next start fail with "The procedure entry
point X could not be located in the dynamic link library ...Qt6QuickWidgets.dll"
or "The code execution cannot proceed because Qt6Multimedia.dll was not found".

The check that was supposed to catch this probed only Qt6Core.dll and
Qt6Network.dll, so it was blind to the two files the reports name. It also only
warned and then installed anyway, so a caught problem was still compounded.

- Scan every file the purge targets with FindFirst, which takes the same
  wildcards the Delete/RMDir calls use, instead of two hardcoded names
- Widen IsExistingCockatriceInstall from Qt6Core.dll to Qt6*.dll so a directory
  left holding only some Qt modules is still recognised as an install
- Gate the scan on that, so a first-time install into a directory that happens to
  hold unrelated Qt5 DLLs is not turned into an abort
- Abort with a non-zero exit code instead of warning, so the new payload is
  never written next to files of the previous version
- Skip the message box on an unattended /S install, which would block on it
  forever, matching the two existing abort sites

* [Installer] Cancel a silent upgrade when the running-instance guard fails

The running instance guard is what stands between a locked install directory
and the mixed Qt DLL set that breaks the next start, but it failed open in two
places and let the install carry on anyway:

- powershell.exe could not be started: the check warned and returned
- the process lookup answered "could not determine": WaitForAppToClose returned
  on $R0 != 1, so "unknown" was treated exactly like "not running"

On a silent upgrade there is nobody at the keyboard to close the applications in
response to that warning, which is precisely the state in which the launcher was
still holding its Qt DLLs mapped.

- Treat "could not determine" as its own state rather than folding it into "not
  running", in all three places WaitForAppToClose inspects the result
- Cancel a silent upgrade in both cases; keep an interactive install on the
  existing warn-and-continue path, since refusing there would leave an installer
  that can never complete and the stale-runtime check still applies
- Route every abort through one AbortInstall so the dialog-vs-silent decision
  lives in a single place, and stop the two pre-existing warning dialogs that a
  /S install without /R would have blocked on forever

* [Installer] Wait on the process that launched the update installer

The in-app updater starts the installer and only then quits itself, so the
installer has to decide whether the launching process is still alive while it is
running. It did that by polling powershell.exe for an image name under the
install directory, which is a proxy for the real question - is the process that
started me still holding its Qt DLLs mapped - and a proxy that can answer "not
running" or "could not determine" while the process is very much alive. Either
answer let the install carry on into the mixed Qt DLL set.

Take /PID= from the command line and wait on that process handle directly:

- OpenProcess with PROCESS_SYNCHRONIZE only, which is all that is needed to wait
  and the least likely to be refused for an unrelated reason
- WaitForSingleObject for the same 60 s the name based check allows, so a client
  slow to flush its card database is not squeezed
- Treat an unopenable handle as "already exited", which is what it means for a
  dead process
- Cancel the upgrade if the launcher has not exited, rather than replacing files
  it is still using

Run it before EnsureAppsNotRunning, not after: it needs no powershell.exe, so it
is the one check that still works on a machine where the process lookup cannot
run, and it is what lets that case keep upgrading instead of being cancelled by
what was only ever a proxy for it. Keep an older client, which passes no /PID=,
on the existing path.

An older installer ignores /PID=, and /D= stays the last parameter either way.

* [Client] Pass our process id to the update installer

The updater starts the installer and only then quits itself, so the installer
begins replacing files while this process still has every Qt runtime DLL mapped,
and a mapped DLL cannot be deleted. It has to work out that it must wait, and
until now all it had was its own image name to go on.

Pass /PID= so it can wait on this process directly instead of guessing, and
cancel the update rather than replace files this process is still using. /D=
stays the last parameter, which NSIS requires.

* [CI] Fail the build when the installer is missing a Qt runtime DLL

The Windows payload is assembled by globbing every *.dll out of the build output
directories, so nothing but luck decides whether a module the client links
statically - Qt6Multimedia.dll, Qt6QuickWidgets.dll - reaches the installer at
all. A module that is left out is invisible until a user installs an update and
the client then refuses to start, with "The code execution cannot proceed because
Qt6Multimedia.dll was not found" or a missing procedure entry point in a
Qt6QuickWidgets.dll carried over from the previous version.

Derive the required list from SERVATRICE_QT_MODULES, COCKATRICE_QT_MODULES and
ORACLE_QT_MODULES, the same module lists the targets are actually built against,
so it cannot drift from what the binaries need, and write it next to the CMake
cache as required-qt-runtime.txt. Add the platform plugin, without which the
client cannot start at all.

Assert it in the existing 7-Zip pass that already rejects build-tree artifacts,
comparing exact relative paths so a same-named file in the wrong directory does
not satisfy a requirement, and normalizing the archive's own separators so it
does not matter which one NSIS chose. Missing manifest, missing entry or missing
7-Zip-independent failures all stop the build rather than warn, which is the
whole point: the previous check already failed hard for artifacts, and a warning
here is what let a broken package ship.

* [Client] Point the Windows qt.conf translations path at the real one

The Windows qt.conf carried Translations = Resources/translations, copied from
the macOS bundle block, but the Windows install has no Resources directory: the
.qm files are installed to COCKATRICE_WIN32_QM_INSTALL_DIR, which is
translations, and main.cpp looks for them at applicationDirPath() +
"/translations". Leave the macOS value alone, which is correct for it.

* [Installer] Close the /? help string so the installer compiles again

The last line of the continued MessageBox string ended in $\n\" instead of $\n".
NSIS reads \" as an escaped quote, so the string never terminated and makensis
rejected the script, which took the CPack packaging step down with it. The
Windows build failed on "Problem running NSIS command" before ever reaching the
new required-runtime check.

* [Installer] Give FindFirst the two arguments it takes

FindFirst takes the pattern and the output variable and leaves the search
handle in $0. There is no handle argument, and FindNext taking only a variable
is the giveaway: the handle has to live somewhere implicit, and it is $0.

The call passed three arguments, so makensis rejected the script and the CPack
packaging step failed with "Problem running NSIS command" before ever reaching
the required-runtime check.

This was not only a compile error. The handle variable was never populated, so
the stale-runtime check that commit 844a1a9b added could never see a match: it
probed $StaleFindHandle, which stayed empty, and the widened wildcard scan over
the purge set was a no-op. Pass the pattern, test $0, and close $0. No FindNext
is needed, because $R0 is a yes/no flag rather than a list.

Drops the now unused StaleFindHandle Var.

* [CI] Dump the NSIS compile log when packaging fails

CPack reports a failed makensis run as "Problem running NSIS command" plus a
path to NSISOutput.log, and that log exists only on the runner, so the actual
compile error never reaches the workflow output. An installer script that does
not compile is therefore undiagnosable from CI alone, which is exactly how a
three-argument FindFirst cost two build cycles here.

* [CI] Run the NSIS log dump under bash

The dump step used the default pwsh shell, where `find` resolves to Windows'
text-search find.exe rather than a file finder, so it died with "FIND:
Parameter format not correct" instead of printing anything. Pin shell: bash.

* [Installer] Test the error flag with LogicLib's ${Errors} condition

makensis rejected the script with "macro _If requires 4 parameter(s), passed
2" at the ClearErrors/OpenProcess pair. A ${If} condition cannot be a bare
$Var: LogicLib needs either a comparison or one of its flag macros, and a lone
$Errors splits into too few arguments for _If. ${Errors} is the documented flag
condition and is the same mechanism as the ${Silent} test already used here.

The dump step earned its keep - this took one run to locate rather than another
round of guessing.

* [Installer] Give FindFirst its handle back and stop blocking silent aborts

makensis: "FindFirst expects 3 parameters, got 2. Usage: FindFirst
$(user_var: handle output) $(user_var: filename output) filespec".

The previous commit was wrong about this. FindFirst does take the handle
explicitly, and FindNext finds it in $0 as a side effect of the FindFirst that
set it, which is why FindNext takes only a variable. The three-argument form the
stale-runtime check was written with was correct all along, and the two-argument
form broke it. Restored, along with Var StaleFindHandle.

That same commit inverted the interactive check in both abort paths.
`${If} $ReinstallMode = 1` with `${OrIfNot} ${Silent}` shows the message box
exactly when the install is the silent /R upgrade - the one case the comment
says must never block, since nothing will dismiss it and the installer hangs
until it is killed. Replaced with `${IfNot} ${Silent}`, so the box appears only
for an interactive install and a silent abort goes straight to SetErrorLevel.

Audited every construct added by this branch against master, which compiles:
all 9 LogicLib condition shapes have a master precedent, FindFirst is the only
FindFirst and is back to 3 arguments, Function/Section and block depth balance,
and the 12 lines with an odd number of quotes are the same pre-existing string
continuations master has.

* [CI] Strip the CR 7-Zip leaves on its paths before comparing them

The installer check reported all 14 required files missing, including
Qt6Core-era basics an 82 MB package obviously contains, so the payload was fine
and the comparison was broken. 7-Zip writes CRLF on Windows, sed 's/^Path = //p'
kept the \r on every line, and grep -qxF matches whole lines - so every entry
carried a trailing \r and nothing matched. The build-tree artifact check next to
it uses an unanchored grep -E and was unaffected, which is what made the two
checks disagree.

Reproduced locally against a 7z archive with CRLF injected: the old pipeline
matches nothing, the new one finds the present files and reports only the
genuinely absent one.

Also match case-insensitively. That is how the installed tree behaves - the
uninstaller clears both $INSTDIR\plugins and $INSTDIR\Plugins - so a
Plugins vs plugins difference in the archive is not a missing file.

* [CI] Print both sides of the installer path comparison, and strip its prefixes

The check still reported all 14 files missing after the CR fix, so the CR was
not the cause and the real difference between 7-Zip's output and the manifest
is still unknown. Inferring it again is what produced two wrong fixes in a row,
so on failure the check now prints the required list and the first 40 paths
7-Zip lists. A bare "missing" line cannot distinguish a missing file from a
misread listing, and twice it was the latter.

Also strip $INSTDIR/, $OUTDIR/, $PLUGINSDIR/ and ./ prefixes from the listed
paths. Written as separate -e expressions rather than one alternation: with |
as the sed delimiter an escaped \| is a literal pipe, so the alternation
version silently matched nothing at all.

Verified against a 7z archive fed through seven path shapes - plain, Windows
backslashes with CRLF, each prefix, lowercase plugins, and all of them combined
- each reporting only the file genuinely absent from the archive.

* [CI] Strip CR from the manifest side of the installer path check

The dump added last run settled it: 7-Zip listed 209 paths including Qt6Gui.dll,
Qt6Multimedia.dll and Plugins/platforms/qwindows.dll verbatim, as plain relative
paths with no prefix - so the archive side was already clean and the previous
fix was right to strip CR there but not sufficient.

The trailing CR was on the other side. CMake writes required-qt-runtime.txt
with the platform line ending, so on Windows $entry arrived as
"Qt6Gui.dll\r", which cannot whole-line match a clean "Qt6Gui.dll". Every
entry then read as missing while the listing held it, which is precisely what
the two sides of the dump showed.

Reproduced locally with a CRLF manifest against a 7z archive: without the strip
every entry is reported missing, with it only the genuinely absent file is.
Checked across plain paths, Windows backslashes with CRLF, $INSTDIR, $OUTDIR,
$PLUGINSDIR, ./ and / prefixes.

The literal $INSTDIR-style prefixes are written as [$] bracket expressions so
shellcheck does not read them as unexpanded variables; that clears the SC2016
that failed the format job, without a disable directive.

* [CI] Reword the bracket-expression comment so the linter stops parsing it

A comment whose first word is shellcheck is read as a directive, so
"# shellcheck does not read these as unexpanded shell variables" was parsed as
a directive named "does" and failed with SC1073 and SC1072 - which cascaded into
the SC1009 on the else clause above it. The SC2016 it was meant to document is
already gone now that the prefixes are written as [$] bracket expressions, so
the note only had to stop looking like a directive.

* [CI] Indent the diagnostic path lists without sed, and undo comment drift

SC2001 asks for parameter expansion instead of sed 's/^/  /'. Echoing
${manifest//$'\n'/$'\n  '} prepends the two spaces directly and keeps the
diagnostic block readable. Both occurrences are converted; only the one reading
from a here-string was reported, but the piped one has the same shape.

Also restore master's wording of the build-tree artifact comment. An earlier
edit inserted "in " ("not the install)" became "not in the install)"). It has no
functional effect, but it was an unintended change to a line this branch has no
reason to touch, and .ci/compile.sh now differs from master only by additions.

* [CI] Ship the Qt module runtimes the Windows package check requires

The installer check reports Qt6Concurrent.dll and Qt6ShaderTools.dll missing
from the Windows package. Both are genuinely absent, not another comparison
artifact - the manifest now matches 12 of 14 entries and the dual-list
diagnostic shows the other two are not in the archive at all.

Nothing deploys them. fixup_bundle copies only the DLLs it finds in
Cockatrice.exe's import table, and the *.dll glob only sees what was already
copied next to the executable. Qt6::Concurrent is linked but never imported,
because QtConcurrent::run and QFutureWatcher are header-only in Qt 6, so the
linker drops the import; Qt6::ShaderTools is skipped the same way. The package
has shipped without them and the client still starts, but that is luck rather
than intent, and required-qt-runtime.txt asks for both.

Install the runtime of every linked module explicitly so the installer contains
what the check demands. An unused DLL costs a few hundred kilobytes; a missing
one is what stops the client starting after an update, and stays invisible until
then.

Derive both the manifest and the install list from the imported target's TYPE,
so a statically linked Qt module - which ships no runtime at all - is skipped by
each and the two can never disagree. Scope the manifest to the client rather
than the union across server, client and Oracle: only the client is packaged on
Windows, and the other two module lists are subsets of the client's, so this
changes nothing today.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 15:15:29 +02:00
32 changed files with 696 additions and 81 deletions

View file

@ -352,6 +352,62 @@ if [[ $MAKE_PACKAGE ]]; then
exit 1
fi
echo "Installer content is clean"
# Fail the build if the installer is missing a Qt runtime the applications
# link against. The DLLs reach the package by globbing every *.dll out of
# the build output directories, so a module the client links statically
# (Qt6Multimedia.dll, Qt6QuickWidgets.dll) can be left out without anything
# else noticing, and the only symptom is a client that refuses to start
# after an update. required-qt-runtime.txt is written by the top level
# CMakeLists.txt from the same module list the targets are built against.
if [[ ! -f required-qt-runtime.txt ]]; then
echo "::error file=$0::required-qt-runtime.txt not found in the build dir, cannot verify the Qt runtime"
exit 1
fi
# 7-Zip writes CRLF on Windows, so the \r has to come off before any
# whole-line comparison below. Normalize separators to /, and strip the
# install-root prefixes an NSIS listing may carry, so the entries can be
# compared as exact relative paths. -xF keeps a '.' in a DLL name from
# being a wildcard; -i because that is how the installed tree behaves -
# the uninstaller clears both $INSTDIR\plugins and $INSTDIR\Plugins.
# The archive side and the manifest side have to agree on line endings.
# CMake writes required-qt-runtime.txt with the platform's line ending and
# 7-Zip writes CRLF on Windows, so a trailing \r survives on $entry and
# defeats the whole-line match - every entry then reads as missing even
# though the listing holds it verbatim.
# [$] rather than \$ so the literal dollar is a bracket expression and the
# linter does not read these as unexpanded shell variables (SC2016).
installer_paths="$("$seven_zip" l -slt "$package" | tr -d '\r' |
sed -n 's/^Path = //p' |
sed -e 's|\\|/|g' -e 's|^[$]INSTDIR/||' -e 's|^[$]OUTDIR/||' -e 's|^[$]PLUGINSDIR/||' -e 's|^\./||' -e 's|^/||')"
manifest="$(tr -d '\r' <required-qt-runtime.txt)"
missing=""
checked=0
while IFS= read -r entry; do
if [[ -z $entry || $entry == \#* ]]; then
continue
fi
checked=$((checked + 1))
if ! grep -qxiF "$entry" <<<"$installer_paths"; then
missing+=" $entry"
fi
done <<<"$manifest"
if [[ -n $missing ]]; then
echo "::error file=$0::Installer is missing required Qt runtime files:$missing"
# Print both sides of the comparison. A mismatch here has twice been
# caused by the shape of 7-Zip's output rather than by a missing file,
# and a bare "missing" line cannot tell those apart.
echo "required-qt-runtime.txt asked for $checked path(s); 7-Zip listed $(grep -c '' <<<"$installer_paths") path(s)"
# Indent with parameter expansion rather than sed 's/^/ /', which the
# linter reports as SC2001.
echo "--- required ---"
echo "${manifest//$'\n'/$'\n '}"
echo "--- first 40 paths 7-Zip lists in the installer ---"
listed_head="$(head -40 <<<"$installer_paths")"
echo "${listed_head//$'\n'/$'\n '}"
exit 1
fi
echo "Installer contains the full Qt runtime ($checked files)"
fi
echo "::endgroup::"
fi

View file

@ -458,6 +458,17 @@ jobs:
VCPKG_FEATURE_FLAGS: dependencygraph
run: .ci/compile.sh --server --test --vcpkg
# CPack reports a failed makensis run as "Problem running NSIS command"
# plus a path to NSISOutput.log, and that log lives only on the runner, so
# the actual compile error is otherwise nowhere in the workflow output.
# Dump it, or a broken installer script is undiagnosable from CI alone.
# shell: bash matters here - the default pwsh resolves `find` to Windows'
# text-search find.exe, which errors instead of searching.
- name: "Dump NSIS compile log"
if: failure() && matrix.os == 'Windows'
shell: bash
run: find . -name NSISOutput.log -exec cat {} +
# 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

View file

@ -326,6 +326,39 @@ elseif(WIN32)
# Configure file with custom definitions for NSIS.
configure_file("${COCKATRICE_CMAKE_PATH}/NSIS.definitions.nsh.in" "${PROJECT_BINARY_DIR}/NSIS.definitions.nsh")
# Every Qt module the client links against, as the DLL names a Windows install
# has to contain, plus the platform plugin without which the client cannot
# start. .ci/compile.sh reads this and fails the build when the packaged
# installer is missing one of them.
#
# Only the client is packaged on Windows, so this is the client's module list
# rather than the union across server, client and Oracle: demanding Oracle's or
# Servatrice's modules of the client installer checks nothing about the client.
#
# A statically linked Qt module ships no runtime DLL, so requiring one would
# fail the package check on a static build for no reason. cockatrice/CMakeLists.txt
# installs the runtime of every module listed here, and skips static ones the
# same way, so the two can never disagree.
#
# A DLL missing from the package only surfaces on a user's machine, after an
# update has already replaced their install, as "The code execution cannot
# proceed because Qt6Multimedia.dll was not found" or as a missing procedure
# entry point in a Qt6QuickWidgets.dll left over from the previous version.
# Assert it while the installer is still being built instead.
set(_required_qt_runtime "")
foreach(_qt_module IN LISTS COCKATRICE_QT_MODULES)
get_target_property(_qt_module_type ${_qt_module} TYPE)
if(_qt_module_type STREQUAL "SHARED_LIBRARY")
string(REGEX REPLACE "^Qt6::" "" _qt_component "${_qt_module}")
list(APPEND _required_qt_runtime "Qt6${_qt_component}.dll")
endif()
endforeach()
list(APPEND _required_qt_runtime "Plugins/platforms/qwindows.dll")
list(REMOVE_DUPLICATES _required_qt_runtime)
list(SORT _required_qt_runtime)
string(REPLACE ";" "\n" _required_qt_runtime "${_required_qt_runtime}")
file(WRITE "${PROJECT_BINARY_DIR}/required-qt-runtime.txt" "${_required_qt_runtime}\n")
# include vcredist into the package; NSIS will take care of running it
if(VCREDISTRUNTIME_FOUND)
install(FILES "${VCREDISTRUNTIME_FILE}" DESTINATION ./)

View file

@ -15,6 +15,12 @@ Var ReinstallMode
Var AllowForceClose
Var GuardDir
Var OldUninstallString
Var StaleFindHandle
Var StaleFindName
Var IsUpgrade
Var AbortReason
Var WaitPid
Var LauncherExited
!include LogicLib.nsh
!include FileFunc.nsh
@ -88,6 +94,7 @@ ${IfNot} ${Errors}
/PORTABLE : Install in portable mode$\n\
/S : Silent install$\n\
/R : Silent upgrade$\n\
/PID=%pid% : Process to wait for before replacing files (used by the in-app updater)$\n\
/D=%directory% : Specify destination directory$\n"
Quit
${EndIf}
@ -115,6 +122,23 @@ ${Else}
StrCpy $ReinstallMode 0
${EndIf}
; /PID= carries the process id of the application that started this installer.
; The in-app updater launches the installer and only then quits itself, so
; without it the installer has to guess whether the launching process is still
; alive by polling powershell.exe for an image name - a guess that can come back
; "not running" or "could not determine" while the process is very much alive and
; still has its Qt DLLs mapped. An older client does not pass /PID= and an older
; installer ignores it, so both sides of the pair have to keep working.
; Note that /D= must stay the last parameter on the command line, which is why
; the client puts it last and this one anywhere.
StrCpy $WaitPid ""
StrCpy $LauncherExited 0
ClearErrors
${GetOptions} $9 "/PID=" $8
${IfNot} ${Errors}
StrCpy $WaitPid $8
${EndIf}
${If} $InstDir == ""
; User did not use /D to specify a directory,
; we need to set a default based on the install mode
@ -134,17 +158,24 @@ ${EndIf}
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
Call SetModeDestinationFromInstdir
; Make sure no application instance is still running (and holding file locks)
; before the previous version is uninstalled. On a silent update (/R /S) the
; running processes are asked to close gracefully and waited for; Cockatrice
; itself is never force-killed (data loss, e.g. an unsaved deck) so the update
; is aborted instead, while the console applications Oracle and Servatrice are
; force-stopped by PID once the grace period is over. On interactive installs
; Wait for the process that launched this installer to exit before anything is
; replaced or deleted. It goes first because it is the one check here that needs
; no powershell.exe: on a machine where the process lookup cannot run at all, the
; launcher is still proven gone here instead of the whole upgrade being cancelled
; by a check that was only ever a proxy for this one.
;
; Then make sure no *other* application instance is still running (and holding
; file locks) before the previous version is uninstalled. On a silent update
; (/R /S) the running processes are asked to close gracefully and waited for;
; Cockatrice itself is never force-killed (data loss, e.g. an unsaved deck) so the
; update is aborted instead, while the console applications Oracle and Servatrice
; are force-stopped by PID once the grace period is over. On interactive installs
; the user is prompted to close them instead.
; Only needed this early for /R, where AutoUninstallIfNeeded runs below; for
; interactive installs the section re-checks after the directory page, when
; $INSTDIR is the directory the user actually picked.
${If} $ReinstallMode = 1
Call WaitForLauncherToExit
Call EnsureAppsNotRunning
${EndIf}
@ -331,8 +362,13 @@ Function WaitForAppToClose
Call IsAppRunning
; Only a definite "running" is worth waiting for. "Not running" is done, and
; "could not determine" must not keep the installer waiting on an answer it
; will never get - EnsureAppsNotRunning has warned about that case already.
${If} $R0 != 1
; will never get - but it cannot simply be waved through either, see
; HandleUnknownAppState.
${If} $R0 = 0
Return
${EndIf}
${If} $R0 = 2
Call HandleUnknownAppState
Return
${EndIf}
@ -351,7 +387,8 @@ Function WaitForAppToClose
DetailPrint "$R1 closed."
Return
${EndIf}
${If} $R0 != 1
${If} $R0 = 2
Call HandleUnknownAppState
Return
${EndIf}
System::Call 'kernel32::GetTickCount()i.s'
@ -366,18 +403,9 @@ Function WaitForAppToClose
; update and preserves the user's data.
${If} $AllowForceClose = 0
DetailPrint "$R1 is still running after 60 s; aborting the update so that no data is lost."
; The in-app updater runs this installer silent and detached and
; never looks at its exit code, so a plain Abort would leave the user
; with no update and no explanation. A /S install without /R on the
; other hand is unattended - by a package manager, for instance -
; and a message box there would block forever, so that case only
; reports through the log and the exit code.
${If} $ReinstallMode = 1
MessageBox MB_OK|MB_ICONEXCLAMATION \
"$R1 did not close within 60 seconds, so the installation was aborted to avoid losing unsaved data.$\r$\n$\r$\nPlease close $R1 and run the installer again."
${EndIf}
SetErrorLevel 1
Abort
StrCpy $AbortReason \
"$R1 did not close within 60 seconds, so the installation was aborted to avoid losing unsaved data.$\r$\n$\r$\nPlease close $R1 and run the installer again."
Call AbortInstall
${EndIf}
; Oracle and Servatrice are console applications: CloseMainWindow()
; returned false for them and waiting longer cannot help. They hold no
@ -390,16 +418,14 @@ Function WaitForAppToClose
DetailPrint "$R1 closed."
Return
${EndIf}
${If} $R0 != 1
${If} $R0 = 2
Call HandleUnknownAppState
Return
${EndIf}
DetailPrint "$R1 is still running after 60 s; aborting the update."
${If} $ReinstallMode = 1
MessageBox MB_OK|MB_ICONEXCLAMATION \
"$R1 could not be stopped, so the installation was aborted.$\r$\n$\r$\nPlease close $R1 and run the installer again."
${EndIf}
SetErrorLevel 1
Abort
StrCpy $AbortReason \
"$R1 could not be stopped, so the installation was aborted.$\r$\n$\r$\nPlease close $R1 and run the installer again."
Call AbortInstall
${Else}
ck_wait_prompt:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
@ -409,7 +435,8 @@ Function WaitForAppToClose
${If} $R0 = 0
Return
${EndIf}
${If} $R0 != 1
${If} $R0 = 2
Call HandleUnknownAppState
Return
${EndIf}
Goto ck_wait_prompt
@ -418,12 +445,102 @@ Function WaitForAppToClose
${EndIf}
FunctionEnd
; A process lookup came back "could not determine": powershell.exe ran but the
; answer was not usable, e.g. the process could not be inspected at all. That
; state is fine to carry on from when the user is at the keyboard - they can
; close the applications by hand and the stale-runtime check still stands
; between a failed guard and a mismatched install.
;
; It is not fine on a silent upgrade. There the whole point of this guard is to
; know that nothing holds locks on the files about to be replaced, and this is
; exactly the state where that is unknown, with no one to click a dialog: the
; old code returned and let the install carry on into the mixed Qt DLL set that
; made the next start fail. Cancel the update instead; the next attempt runs
; with the applications closed and succeeds.
Function HandleUnknownAppState
${If} $ReinstallMode = 1
StrCpy $AbortReason \
"The installation was cancelled because it could not determine whether Cockatrice is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again."
Call AbortInstall
${EndIf}
DetailPrint "Could not determine whether $R1 is running; close Cockatrice, Oracle and Servatrice manually if the installation reports a problem."
${IfNot} ${Silent}
MessageBox MB_OK|MB_ICONEXCLAMATION \
"This installer could not determine whether $R1 is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing."
${EndIf}
FunctionEnd
Function EnsureAppsNotRunning
; guards the install directory
StrCpy $GuardDir $INSTDIR
Call GuardAppsNotRunning
FunctionEnd
; Aborts the installation, showing $AbortReason to the user.
;
; Every abort goes through here so the unattended case is handled the same way
; everywhere: the in-app updater runs this installer silent and detached and
; never looks at its exit code, so a plain Abort would leave the user with no
; update and no explanation, while a /S install without /R is unattended - by a
; package manager, for instance - and a message box there would block forever.
; Only the first of those two gets a dialog; the second reports through the log
; and the exit code.
Function AbortInstall
${IfNot} ${Silent}
MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason"
${EndIf}
SetErrorLevel 1
Abort
FunctionEnd
; Waits for the application that launched this installer to exit, using the /PID=
; it passed. Sets $LauncherExited to 1 when that process is provably gone.
;
; This is the authority for the one question the rest of the installer can only
; approximate: is the process that started us still holding its Qt DLLs mapped?
; A mapped module cannot be deleted, so an answer of "not running" obtained by
; enumerating process names is what allows the update to write a new payload on
; top of files it could not remove. Waiting on a real handle has no such gap,
; needs no powershell.exe, and is unaffected by a process whose path cannot be
; read - the case the name based check has to treat as "not ours" so it never
; closes a third party application.
;
; Sets $LauncherExited to 0 when no usable /PID= was given, which is the older
; client that does not pass one; the caller then has no authority available and
; falls back to the name based guard.
Function WaitForLauncherToExit
StrCpy $LauncherExited 0
${If} $WaitPid == ""
Return
${EndIf}
; PROCESS_SYNCHRONIZE is the only access this needs - enough to wait on the
; handle, and the least likely to be refused for a reason that has nothing to
; do with the upgrade.
ClearErrors
System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.r0'
${If} ${Errors}
; The launcher is gone - OpenProcess cannot open a dead process - which is
; exactly the state the wait was for.
DetailPrint "The application that started this installer has already exited."
StrCpy $LauncherExited 1
Return
${EndIf}
DetailPrint "Waiting for the application that started this installer to exit ..."
; Same 60 s grace period the name based check uses, so a client that is slow
; to flush its card database does not get less room than it used to.
; WAIT_OBJECT_0 is 0; WAIT_TIMEOUT is 258 and a failed wait reports 0xFFFFFFFF.
System::Call 'kernel32::WaitForSingleObject(p r0, i 60000) i.r1'
System::Call 'kernel32::CloseHandle(p r0)'
${If} $R1 != 0
DetailPrint "The application that started this installer did not exit within 60 s."
StrCpy $AbortReason \
"The application that started this update has not exited yet, so the installation was cancelled rather than replace files it is still using.$\r$\n$\r$\nPlease close Cockatrice and run the installer again."
Call AbortInstall
${EndIf}
DetailPrint "The application that started this installer has exited."
StrCpy $LauncherExited 1
FunctionEnd
Function EnsureOldInstallNotRunning
; Guards the directory of a *previous* installation, as recorded in the
; registry. Needed by componentsPagePre, which runs the old uninstaller
@ -447,13 +564,32 @@ Function GuardAppsNotRunning
Call IsProcessLookupAvailable
${If} $R0 = 0
; Without PowerShell we cannot tell whether a running instance is
; holding locks on the files we are about to replace. Warn once and
; continue: refusing to install would leave the user with an installer
; that can never complete, which is worse than the locked-file failure
; this check exists to prevent. MessageBox is shown even in a silent
; install - the user has to be told to close the applications by hand.
; holding locks on the files we are about to replace.
DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the installation reports a problem."
MessageBox MB_OK|MB_ICONEXCLAMATION "This installer could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing."
; On a silent upgrade that used to be a warning and nothing more, which
; left the very process that launched the installer free to go on
; replacing files it still had mapped - the one outcome this guard exists
; to prevent - and then writing the new payload on top of them. Cancel
; instead. An interactive install can still continue: the user is at the
; keyboard, and refusing outright would leave them with an installer that
; can never complete.
;
; Unless the launcher has already been waited on directly: WaitForLauncherToExit
; needs no powershell.exe, so when it has run, Cockatrice is known to be
; gone and the lookup failing to run costs nothing but the Oracle and
; Servatrice checks. An older client that passes no /PID= leaves no such
; authority, and there the upgrade is cancelled as before.
${If} $LauncherExited != 1
${AndIf} $ReinstallMode = 1
StrCpy $AbortReason \
"The installation was cancelled because it could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again."
Call AbortInstall
${EndIf}
; The user has to be told to close the applications by hand, but a /S
; install without /R is unattended and would block on this forever.
${IfNot} ${Silent}
MessageBox MB_OK|MB_ICONEXCLAMATION "This installer could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing."
${EndIf}
Return
${EndIf}
StrCpy $R1 "cockatrice.exe"
@ -494,27 +630,65 @@ Function IsExistingCockatriceInstall
; executables and uninstall.exe but silently skipped the DLLs its running
; instance had locked - which is exactly the broken install the purge is
; meant to heal, and without these markers none of the files above would be
; there to prove that it is one.
${If} ${FileExists} "$INSTDIR\Qt6Core.dll"
StrCpy $R0 1
${EndIf}
; there to prove that it is one. Every Qt module Cockatrice links is part of
; the payload, so match them all: probing only Qt6Core.dll would miss a
; directory where that one file happened to be deletable and the others
; were not.
StrCpy $R3 "Qt6*.dll"
Call CheckStaleRuntimeMask
${If} ${FileExists} "$INSTDIR\qt.conf"
StrCpy $R0 1
${EndIf}
FunctionEnd
; Sets $R0 to 1 if a runtime file of a previous version is still present after
; the purge. NSIS silently skips whatever it cannot delete, so one surviving DLL
; is enough to prove that something still holds a lock on the install
; directory. The purge works on wildcards, which the file existence checks do
; not, so this looks for files that are certainly part of the payload.
; the purge. NSIS silently skips whatever it cannot delete, so one surviving file
; is enough to prove that something still holds a lock on the install directory.
;
; The masks below have to cover the purge above one for one. A check that only
; probed named files could not see a locked Qt6Multimedia.dll or a Plugins
; directory the purge was unable to remove, so the exact files that broke the
; install were also the ones this check was blind to.
Function HasStaleRuntimeFiles
StrCpy $R0 0
${If} ${FileExists} "$INSTDIR\Qt6Core.dll"
StrCpy $R3 "Qt*.dll"
Call CheckStaleRuntimeMask
StrCpy $R3 "libcrypto*.dll"
Call CheckStaleRuntimeMask
StrCpy $R3 "libssl*.dll"
Call CheckStaleRuntimeMask
StrCpy $R3 "zlib*.dll"
Call CheckStaleRuntimeMask
StrCpy $R3 "icu*.dll"
Call CheckStaleRuntimeMask
StrCpy $R3 "libeay32.dll"
Call CheckStaleRuntimeMask
StrCpy $R3 "ssleay32.dll"
Call CheckStaleRuntimeMask
${If} $R0 = 1
Return
${EndIf}
${If} ${FileExists} "$INSTDIR\libmysql.dll"
StrCpy $R0 1
Return
${EndIf}
; IfFileExists takes wildcards, so a Plugins directory the purge could not
; remove counts as a leftover too.
${If} ${FileExists} "$INSTDIR\Plugins\*.*"
StrCpy $R0 1
${EndIf}
${If} ${FileExists} "$INSTDIR\Qt6Network.dll"
FunctionEnd
; usage: set $R3 to a file mask, call this. Sets $R0 to 1 when $INSTDIR holds a
; matching file. Returns early once something was found, so $R0 stays 1.
Function CheckStaleRuntimeMask
${If} $R0 = 1
Return
${EndIf}
FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\$R3"
${If} $StaleFindHandle != ""
StrCpy $R0 1
FindClose $StaleFindHandle
${EndIf}
FunctionEnd
@ -839,6 +1013,12 @@ ${EndIf}
; Remove any runtime DLLs left over from older versions, so a mismatched
; Qt/OpenSSL set cannot survive next to the new executables.
Call IsExistingCockatriceInstall
; Remembered because the stale-runtime check below must only ever fire for a
; directory this installer recognised as a Cockatrice install. Scanning for
; leftovers in an unrelated directory (a first-time install into a folder that
; happens to hold some Qt5 DLLs, say) would otherwise turn those files into an
; abort reason even though the payload is about to overwrite nothing of theirs.
StrCpy $IsUpgrade $R0
${If} $PortableMode = 0
${AndIf} $R0 = 1
RMDir /r "$INSTDIR\Plugins"
@ -857,12 +1037,28 @@ ${EndIf}
; performed at all - is still on disk, and the new executables are about to be
; written next to it. That mix is exactly what makes the next start fail with
; "The procedure entry point X could not be located in the dynamic link library
; ...Qt6Network.dll", so say so instead of leaving it to the user to find out.
Call HasStaleRuntimeFiles
; ...Qt6QuickWidgets.dll" or "The code execution cannot proceed because
; Qt6Multimedia.dll was not found", so stop here instead of compounding it.
; Nothing of the new payload has been written at this point, so the next run -
; with the applications closed - gets a clean install.
${If} $IsUpgrade = 1
Call HasStaleRuntimeFiles
${Else}
StrCpy $R0 0
${EndIf}
${If} $R0 = 1
DetailPrint "Files of a previous version are still present; the installation may be incomplete."
MessageBox MB_OK|MB_ICONEXCLAMATION \
"The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again."
DetailPrint "Files of a previous version could not be removed; aborting the installation."
; The in-app updater runs this installer silent and detached and never looks
; at its exit code, so the user has to be told what happened. A /S install
; without /R is unattended - a package manager, for instance - and a message
; box there would block forever, so that case only reports through the log
; and the exit code.
${IfNot} ${Silent}
MessageBox MB_OK|MB_ICONEXCLAMATION \
"The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again."
${EndIf}
SetErrorLevel 1
Abort
${EndIf}
@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@

View file

@ -683,6 +683,24 @@ if(WIN32)
PATTERN ".lupdate*" EXCLUDE
)
# fixup_bundle further down copies only the DLLs it can find in Cockatrice.exe's
# import table, and the glob above only sees DLLs that were already copied next
# to the executable. Qt6::Concurrent is linked but never imported - QtConcurrent::run
# and QFutureWatcher are header-only in Qt 6 - so neither step ships
# Qt6Concurrent.dll, even though required-qt-runtime.txt lists it and the
# Windows package check then fails on a package that is fine to run.
# Qt6::ShaderTools is skipped for the same reason.
#
# Install the runtime of every linked module explicitly. An unused DLL costs a
# few hundred kilobytes; a missing one is what stops the client starting after
# an update, and it stays invisible until then.
foreach(_qt_module IN LISTS COCKATRICE_QT_MODULES)
get_target_property(_qt_module_type ${_qt_module} TYPE)
if(_qt_module_type STREQUAL "SHARED_LIBRARY")
install(FILES "$<TARGET_FILE:${_qt_module}>" DESTINATION ./)
endif()
endforeach()
install(
DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/"
DESTINATION ./
@ -729,11 +747,15 @@ if(WIN32)
PATTERN "tls/qschannelbackend.dll"
)
# The Windows install has no Resources directory: the .qm files go to
# ${COCKATRICE_WIN32_QM_INSTALL_DIR} and main.cpp looks for them at
# applicationDirPath() + "/translations". The macOS bundle above keeps
# Resources/translations, which is correct for it.
install(
CODE "
file(WRITE \"\${CMAKE_INSTALL_PREFIX}/${qtconf_dest_dir}/qt.conf\" \"[Paths]
Plugins = Plugins
Translations = Resources/translations
Translations = translations
Data = Resources\")
"
COMPONENT Runtime

View file

@ -29,13 +29,17 @@ void GameState::incrementGameTime()
void GameState::setGameTime(int _secondsElapsed)
{
secondsElapsed = _secondsElapsed;
emit updateTimeElapsedLabel(formatElapsedTime(_secondsElapsed));
}
QString GameState::formatElapsedTime(int _secondsElapsed)
{
int seconds = _secondsElapsed;
int minutes = seconds / 60;
seconds -= minutes * 60;
int hours = minutes / 60;
minutes -= hours * 60;
emit updateTimeElapsedLabel(QString::number(hours).rightJustified(2, '0') + ":" +
QString::number(minutes).rightJustified(2, '0') + ":" +
QString::number(seconds).rightJustified(2, '0'));
}
return QString::number(hours).rightJustified(2, '0') + ":" + QString::number(minutes).rightJustified(2, '0') + ":" +
QString::number(seconds).rightJustified(2, '0');
}

View file

@ -110,6 +110,11 @@ public:
return hostId;
}
int getSecondsElapsed() const
{
return secondsElapsed;
}
signals:
void updateTimeElapsedLabel(QString newTime);
void gameStarted(bool resuming);
@ -121,6 +126,9 @@ public slots:
void incrementGameTime();
void setGameTime(int _secondsElapsed);
public:
static QString formatElapsedTime(int _secondsElapsed);
private:
QTimer *gameTimer;
int secondsElapsed;

View file

@ -154,6 +154,14 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone)
reorganizeCards();
}
void ZoneViewZoneLogic::clearCards()
{
for (auto card : cards) {
card->deleteLater();
}
cards.clear();
}
void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone)
{

View file

@ -41,6 +41,10 @@ public:
bool prepareAddCard(int x);
void removeCard(int position, bool toNewZone);
void updateCardIds(CardAction action);
/** @brief Removes all cards from the view, without touching anything else. Used in replay rewind */
void clearCards();
int getNumberCards() const
{
return numberCards;

View file

@ -735,6 +735,16 @@ void GameScene::clearViews()
}
}
void GameScene::emptyActiveViews()
{
for (auto zoneView : zoneViews) {
auto logic = qobject_cast<ZoneViewZoneLogic *>(zoneView->getZone()->getLogic());
if (!logic->getRevealZone()) {
logic->clearCards();
}
}
}
/**
* @brief Closes the most recently added zone view.
*/

View file

@ -227,6 +227,9 @@ public slots:
/** @brief Closes all zone views. */
void clearViews();
/** @brief Empties the contents of all non-reveal zone views. Used for replay rewinding */
void emptyActiveViews();
/** @brief Closes the most recently added zone view. */
void closeMostRecentZoneView();
QTransform getViewTransform() const;

View file

@ -1,7 +1,9 @@
#include "message_log_widget.h"
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/card_counter_settings.h"
#include "../../client/sound_engine.h"
#include "../../game/game_state.h"
#include "../../game/phase.h"
#include "../../game/player/player_logic.h"
#include "../../interface/widgets/tabs/tab_game.h"
@ -10,6 +12,7 @@
#include <libcockatrice/protocol/pb/context_move_card.pb.h>
#include <libcockatrice/protocol/pb/context_mulligan.pb.h>
#include <libcockatrice/settings/chat_settings.h>
#include <libcockatrice/utility/zone_names.h>
#include <utility>
@ -626,13 +629,12 @@ void MessageLogWidget::logSetActivePhase(int phaseNumber)
soundEngine->playSound(phase.soundFileName);
appendHtml("<font color=\"" + phase.color + "\"><b>" + QDateTime::currentDateTime().toString("[hh:mm:ss] ") +
phase.getName() + "</b></font>");
appendHtml("<font color=\"" + phase.color + "\"><b>" + getCurrentTime() + phase.getName() + "</b></font>");
}
void MessageLogWidget::logSetActivePlayer(PlayerLogic *player)
{
appendHtml("<br><font color=\"green\"><b>" + QDateTime::currentDateTime().toString("[hh:mm:ss] ") +
appendHtml("<br><font color=\"green\"><b>" + getCurrentTime() +
QString(tr("%1's turn.")).arg(player->getPlayerInfo()->getName()) + "</b></font><br>");
}
@ -826,6 +828,14 @@ void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optiona
ChatView::appendHtmlServerMessage(messagePrefix + html + messageSuffix, optionalIsBold, optionalFontColor);
}
QString MessageLogWidget::getCurrentTime() const
{
if (SettingsCache::instance().chat().getUseGameTime()) {
return "[" + GameState::formatElapsedTime(game->getGameState()->getSecondsElapsed()) + "] ";
}
return ChatView::getCurrentTime();
}
void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEventHandler)
{
connect(playerEventHandler, &PlayerEventHandler::logSay, this, &MessageLogWidget::logSay);

View file

@ -104,6 +104,9 @@ public slots:
void appendHtmlServerMessage(const QString &html,
bool optionalIsBold = false,
QString optionalFontColor = QString()) override;
private:
[[nodiscard]] QString getCurrentTime() const override;
};
#endif

View file

@ -265,8 +265,16 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath)
// NSIS needs the /D= argument to be an UNQUOTED string, even if it contains spaces. Qt likes to quote arguments if
// they contain spaces, so we use the windows exclusive QProcess::setNativeArguments in the only case where this is
// relevant, which preserves the argument unquoted.
//
// /PID= tells the installer which process to wait for before it replaces anything. We launch the installer first
// and only quit afterwards, so it starts while this process is still alive and still has every Qt runtime DLL
// mapped, and a mapped DLL cannot be deleted. Without the handle the installer can only poll for an image name and
// guess, and a wrong guess is what leaves the update writing new executables next to runtime files of the previous
// version, which fails on the next start with "The procedure entry point ... could not be located in the dynamic
// link library
// ...Qt6QuickWidgets.dll". /D= has to stay the last parameter, which is why it is last here too.
#ifdef Q_OS_WIN
process.setNativeArguments(QString("/R /D=%1").arg(appDir));
process.setNativeArguments(QString("/R /PID=%1 /D=%2").arg(QCoreApplication::applicationPid()).arg(appDir));
#else
// Linux/macOS: normal argument passing (not relevant since they update differently.)
process.setArguments({"/R", QString("/D=%1").arg(appDir)});

View file

@ -167,7 +167,7 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold,
QString htmlText =
"<font color=" + ((optionalFontColor.size() > 0) ? optionalFontColor : serverMessageColor.name()) + ">" +
QDateTime::currentDateTime().toString("[hh:mm:ss] ") + html + "</font>";
getCurrentTime() + html + "</font>";
if (optionalIsBold) {
htmlText = "<b>" + htmlText + "</b>";
@ -179,6 +179,11 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold,
}
}
QString ChatView::getCurrentTime() const
{
return QDateTime::currentDateTime().toString("[hh:mm:ss] ");
}
void ChatView::appendCardTag(QTextCursor &cursor, const QString &cardName)
{
QTextCharFormat oldFormat = cursor.charFormat();
@ -290,7 +295,7 @@ void ChatView::appendMessage(QString message,
timeFormat.setForeground(serverMessageColor);
timeFormat.setFontWeight(QFont::Bold);
cursor.setCharFormat(timeFormat);
cursor.insertText(QDateTime::currentDateTime().toString("[hh:mm:ss] "));
cursor.insertText(getCurrentTime());
}
// nickname

View file

@ -107,9 +107,8 @@ public:
ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _showTimestamps, QWidget *parent = nullptr);
void retranslateUi();
void appendHtml(const QString &html);
void virtual appendHtmlServerMessage(const QString &html,
bool optionalIsBold = false,
QString optionalFontColor = QString());
virtual void
appendHtmlServerMessage(const QString &html, bool optionalIsBold = false, QString optionalFontColor = QString());
void appendMessage(QString message,
RoomMessageTypeFlags messageType = {},
const ServerInfo_User &userInfo = {},
@ -119,6 +118,7 @@ public:
QString getRecentChatLog(int maxMessages = 50) const;
protected:
[[nodiscard]] virtual QString getCurrentTime() const;
void enterEvent(QEnterEvent *event) override;
void leaveEvent(QEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;

View file

@ -634,9 +634,10 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
"QMenu::item:selected{background:%4;}")
.arg(colorStr(theme.bg), colorStr(theme.text), colorStr(theme.border), colorStr(theme.buttonHover)));
const bool canJoin = !game.started() && game.player_count() < game.max_players();
// Never grey the join action out: GameSelector::joinGame() asks whether to
// fall back to spectating when the game is full or already running, so a
// disabled entry would just read as a dead button.
QAction *join = menu.addAction(tr("Join game"));
join->setEnabled(canJoin);
QAction *spec = nullptr;
if (game.spectators_allowed()) {

View file

@ -430,9 +430,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
hidePopup(true);
requestVisibleItemResources();
});
// Forward join requests from popup upward
connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested);
} else {
// Dialog mode: keyboard selection drives the Invite button.
connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) {
@ -692,6 +689,17 @@ void UserListWidget::connectPopupSignals()
[this](const QString &n) { userContextMenu->execAdjustJudge(n, true); });
connect(userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this,
[this](const QString &n) { userContextMenu->execAdjustJudge(n, false); });
// Joining is a tab-level action, not a per-user one: the popup's games list
// spans every room the user is in, so the target room may not be open yet.
// Hand it to the supervisor, which owns the intent that joins the room if
// needed. The popup closes here because that intent switches tabs, which
// would otherwise strand it on a list that is no longer on screen.
connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this,
[this](const int gameId, const int roomId, const bool asSpectator) {
hidePopup(true);
tabSupervisor->joinGameFromUserCard(gameId, roomId, asSpectator);
});
}
bool UserListWidget::eventFilter(QObject *obj, QEvent *event)

View file

@ -172,7 +172,6 @@ signals:
void removeBuddy(const QString &userName);
void addIgnore(const QString &userName);
void removeIgnore(const QString &userName);
void joinGameRequested(int gameId, int roomId, bool asSpectator);
void sectionExpanded(Section section, bool expanded);
/** Dialog mode: the user activated (Enter/double-click) the given row. */
void userActivated(const QString &userName);

View file

@ -64,6 +64,10 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(&ignoreAllPrivateMessagesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(),
&ChatSettings::setIgnoreAllPrivateMessages);
useGameTimeCheckBox.setChecked(SettingsCache::instance().chat().getUseGameTime());
connect(&useGameTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(),
&ChatSettings::setUseGameTime);
customAlertString = new QLineEdit();
customAlertString->setText(SettingsCache::instance().chat().getHighlightWords());
connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance().chat(),
@ -82,6 +86,7 @@ MessagesSettingsPage::MessagesSettingsPage()
chatGrid->addWidget(&mentionPopups, 6, 0);
chatGrid->addWidget(&roomHistory, 7, 0);
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0);
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0);
chatGroupBox = new QGroupBox;
chatGroupBox->setLayout(chatGrid);
@ -263,6 +268,7 @@ void MessagesSettingsPage::retranslateUi()
mentionPopups.setText(tr("Enable desktop notification for mentions"));
roomHistory.setText(tr("Enable room message history on join"));
ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
hexLabel.setText(tr("(Color is hexadecimal)"));
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));

View file

@ -41,6 +41,7 @@ private:
QCheckBox mentionPopups;
QCheckBox roomHistory;
QCheckBox ignoreAllPrivateMessagesCheckBox;
QCheckBox useGameTimeCheckBox;
QGroupBox *chatGroupBox;
QGroupBox *highlightGroupBox;
QGroupBox *messageGroupBox;

View file

@ -259,7 +259,7 @@ void TabGame::linkCardToChat(const QString &cardName)
sayEdit->setFocus();
}
void TabGame::resetChatAndPhase()
void TabGame::resetForRewind()
{
// reset chat log
messageLog->clearChat();
@ -267,6 +267,9 @@ void TabGame::resetChatAndPhase()
// reset phase markers
game->getGameState()->setCurrentPhase(-1);
// reset view zones
static_cast<GameScene *>(gameView->scene())->emptyActiveViews();
// reset spectator state so the replay can rebuild it from the start
game->getPlayerManager()->clearSpectators();
playerListWidget->clearSpectators();
@ -1270,7 +1273,7 @@ void TabGame::createReplayDock(GameReplay *replay)
replayDock->setWidget(replayWidget);
replayDock->setFloating(false);
connect(replayWidget, &ReplayWidget::rewound, this, &TabGame::resetChatAndPhase);
connect(replayWidget, &ReplayWidget::rewound, this, &TabGame::resetForRewind);
connect(replayWidget, &ReplayWidget::eventReplayed, game->getGameEventHandler(),
[this](const auto &event, auto options) {
game->getGameEventHandler()->processGameEventContainer(event, nullptr, options);

View file

@ -204,7 +204,7 @@ public:
public slots:
void viewCardInfo(const CardRef &cardRef = {}) const;
void resetChatAndPhase();
void resetForRewind();
void updateTimeElapsedLabel(QString newTime);
void addPlayerToAutoCompleteList(QString playerName);
void removePlayerFromAutoCompleteList(QString playerName);

View file

@ -1100,10 +1100,27 @@ void TabSupervisor::replayLeft(TabGame *tab)
}
void TabSupervisor::joinReportGame(const int gameId, const int roomId)
{
joinGameByIntent(gameId, roomId, true);
}
void TabSupervisor::joinGameFromUserCard(const int gameId, const int roomId, const bool asSpectator)
{
joinGameByIntent(gameId, roomId, asSpectator);
}
/**
* Runs the join/spectate intent for @p gameId in @p roomId.
*
* The intent owns the whole flow: if the room tab is not open yet it joins the
* room first, then retries the game, so callers do not have to care whether the
* room happens to be on screen.
*/
void TabSupervisor::joinGameByIntent(const int gameId, const int roomId, const bool asSpectator)
{
auto *remoteClient = qobject_cast<RemoteClient *>(client);
if (!remoteClient) {
actShowPopup(tr("Report joins are only available on a remote server."));
actShowPopup(tr("Game joins are only available on a remote server."));
return;
}
@ -1112,7 +1129,7 @@ void TabSupervisor::joinReportGame(const int gameId, const int roomId)
ctx->roomContext.serverContext.port = QString::number(remoteClient->peerPort());
ctx->roomContext.roomId = roomId;
ctx->gameId = gameId;
ctx->asSpectator = true;
ctx->asSpectator = asSpectator;
auto *joinGameIntent = new IntentJoinServerGame(this, remoteClient, std::move(ctx));
joinGameIntent->setParent(this);

View file

@ -128,6 +128,8 @@ private:
static QString sanitizeTabName(QString dirty);
static QString sanitizeHtml(QString dirty);
void resetTabsMenu();
/** Shared join/spectate driver behind the report tab and the profile hover card. */
void joinGameByIntent(int gameId, int roomId, bool asSpectator);
public:
explicit TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent = nullptr);
@ -198,6 +200,8 @@ public slots:
TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false);
void openReplay(GameReplay *replay);
void joinReportGame(int gameId, int roomId);
/** Joins or spectates a game listed on a user's profile hover card. */
void joinGameFromUserCard(int gameId, int roomId, bool asSpectator);
void openTabModeration(const QString &userName = {});
void switchToFirstAvailableNetworkTab();
void maximizeMainWindow();

View file

@ -1,6 +1,7 @@
#include "single_instance_manager.h"
#include <QDir>
#include <QPointer>
namespace
{
@ -155,19 +156,44 @@ void SingleInstanceManager::handleNewConnection()
socket->write(ACK_MESSAGE);
socket->flush();
// Drop the payload from the buffer before handling it: the handlers
// run synchronously and can spin a nested event loop (e.g. a modal
// dialog) that re-reads this socket, which would re-parse and re-emit
// the same files.
// Drop the payload from the buffer before handing it off, so a
// re-entrant event loop cannot re-parse and re-emit the same files.
buffer->clear();
*expectedSize = 0;
// Take the guard while the socket is still alive: QPointer has to be
// built from a live pointer, and by the time the handlers below return
// the socket may already be gone.
QPointer<QLocalSocket> guardedSocket(socket);
// Hand the payload over synchronously, exactly as before, so the
// "Join game?" confirmation still appears. A handler can spin a nested
// event loop (that modal box is one), and the sending instance exits as
// soon as it has the ACK above, so while that loop runs the peer closes
// and this socket is disconnected.
emit filesReceived(files);
socket->disconnectFromServer();
// The handlers run synchronously, so returning from the emit means the
// readyRead frame has unwound and it is safe to touch the socket again.
// This teardown must therefore NOT be queued: a queued call would be
// picked up by the nested loop above while the frame is still on the
// stack, which is precisely the use-after-free this guards against.
// The guard covers the one remaining way the socket can die under us --
// the primary being torn down (e.g. quit) while the dialog was open --
// since QLocalServer owns and destroys its pending connections.
if (guardedSocket) {
guardedSocket->disconnectFromServer();
guardedSocket->deleteLater();
}
return;
}
});
connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater);
// Deliberately no `disconnected -> deleteLater()` here. The handlers above run
// synchronously and may spin a nested event loop, during which the sender exits
// and this socket is disconnected. A deleteLater() triggered from there is not
// deferred to the outer loop: Qt frees the object as soon as that nested loop
// unwinds, which is still inside this readyRead emission, so Qt's own signal
// dispatch is left holding a freed socket. The socket is instead closed and
// deleted once the handlers have returned, on the code path above.
}

View file

@ -21,6 +21,7 @@ public:
[[nodiscard]] virtual bool getShowMentionPopup() const = 0;
[[nodiscard]] virtual bool getRoomHistory() const = 0;
[[nodiscard]] virtual bool getIgnoreAllPrivateMessages() const = 0;
[[nodiscard]] virtual bool getUseGameTime() const = 0;
[[nodiscard]] virtual QString getHighlightWords() const = 0;
};

View file

@ -70,6 +70,11 @@ bool ChatSettings::getIgnoreAllPrivateMessages() const
return getValue("ignoreAllPrivateMessages", QString(), QString(), false).toBool();
}
bool ChatSettings::getUseGameTime() const
{
return getValue("useGameTime", QString(), QString(), false).toBool();
}
QString ChatSettings::getHighlightWords() const
{
return getValue("highlightWords").toString();
@ -141,6 +146,11 @@ void ChatSettings::setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages)
setValue(_ignoreAllPrivateMessages, "ignoreAllPrivateMessages");
}
void ChatSettings::setUseGameTime(bool _useGameTime)
{
setValue(_useGameTime, "useGameTime");
}
void ChatSettings::setHighlightWords(const QString &_highlightWords)
{
setValue(_highlightWords, "highlightWords");

View file

@ -24,6 +24,7 @@ public:
[[nodiscard]] bool getShowMentionPopup() const override;
[[nodiscard]] bool getRoomHistory() const override;
[[nodiscard]] bool getIgnoreAllPrivateMessages() const override;
[[nodiscard]] bool getUseGameTime() const override;
[[nodiscard]] QString getHighlightWords() const override;
void setChatMention(bool _chatMention);
@ -39,6 +40,7 @@ public:
void setShowMentionPopups(bool _showMentionPopups);
void setRoomHistory(bool _roomHistory);
void setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages);
void setUseGameTime(bool _useGameTime);
void setHighlightWords(const QString &_highlightWords);
signals:

View file

@ -55,6 +55,17 @@ add_executable(
)
target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt.
if(WITH_CLIENT)
add_executable(
single_instance_manager_test ${CMAKE_SOURCE_DIR}/cockatrice/src/single_instance_manager.cpp
single_instance_manager_test.cpp
)
target_include_directories(single_instance_manager_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
add_test(NAME single_instance_manager_test COMMAND single_instance_manager_test)
endif()
find_package(GTest)
if(NOT GTEST_FOUND)
@ -98,6 +109,7 @@ if(NOT GTEST_FOUND)
add_dependencies(latency_tracker_test gtest)
add_dependencies(metrics_registry_test gtest)
add_dependencies(loader_local_matching_test gtest)
add_dependencies(single_instance_manager_test gtest)
endif()
include_directories(${GTEST_INCLUDE_DIRS})
@ -153,6 +165,10 @@ target_link_libraries(
loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
if(WITH_CLIENT)
target_link_libraries(single_instance_manager_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
endif()
add_subdirectory(card_zone_algorithms)
add_subdirectory(carddatabase)
add_subdirectory(deck_list_model)

View file

@ -306,6 +306,12 @@ TEST_F(SettingsDefaultsTest, Chat_IgnoreAllPrivateMessages_Default)
ASSERT_EQ(s.getIgnoreAllPrivateMessages(), false);
}
TEST_F(SettingsDefaultsTest, Chat_UseGameTime_Default)
{
ChatSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getUseGameTime(), false);
}
// --- PersonalSettings ---
TEST_F(SettingsDefaultsTest, Personal_Lang_Default)

View file

@ -0,0 +1,134 @@
#include "single_instance_manager.h"
#include <QCoreApplication>
#include <QDataStream>
#include <QEventLoop>
#include <QLocalServer>
#include <QLocalSocket>
#include <QTimer>
#include <gtest/gtest.h>
namespace
{
constexpr int HANDSHAKE_TIMEOUT_MS = 5000;
constexpr int MODAL_DIALOG_MS = 50;
const QStringList PAYLOAD{QStringLiteral("cockatrice://joingame?hostname=127.0.0.1&port=4747&roomid=0&gameid=1")};
/// Serializes a payload the way SingleInstanceManager::forwardToPrimary() does.
QByteArray encodePayload(const QStringList &files)
{
QByteArray payload;
QDataStream out(&payload, QIODevice::WriteOnly);
out << files;
QByteArray message;
QDataStream msgStream(&message, QIODevice::WriteOnly);
msgStream << static_cast<quint32>(payload.size());
message.append(payload);
return message;
}
} // namespace
// Regression test for the use-after-free in SingleInstanceManager::handleNewConnection().
//
// The forwarded payload is handed to the handlers from inside the forwarding
// socket's readyRead emission, which is what lets the "Join game?" confirmation
// appear at all. A handler can spin a nested event loop (a modal QMessageBox),
// and the sending instance exits as soon as it has the acknowledgment, so the
// peer closes *while that loop runs*. deleteLater() called from inside such a
// nested loop does not wait for the outer loop: Qt frees the object as soon as
// the nested loop unwinds, still inside the readyRead emission, so Qt's own
// signal dispatch and the handler lambda both end up holding a freed socket.
// Nothing may therefore delete the socket from the disconnected signal, and the
// teardown is queued until that frame has unwound.
TEST(SingleInstanceManagerTest, HandlersRunSynchronouslyAndSocketIsNotTouchedAfterTheyReturn)
{
// Scope the hand-off socket to this test process so a Cockatrice the
// developer happens to be running cannot claim it.
qputenv("USER", QByteArrayLiteral("cockatrice-test-") + QByteArray::number(QCoreApplication::applicationPid()));
SingleInstanceManager primary;
ASSERT_TRUE(primary.tryRun(QStringList())) << "could not become the primary instance";
auto *server = primary.findChild<QLocalServer *>();
ASSERT_NE(nullptr, server) << "the primary instance is not listening";
QLocalSocket sender;
QStringList handled;
QByteArray acknowledgment;
int handlerCalls = 0;
bool socketConnectedWhenHandled = false;
bool socketReleased = false;
// Waited on after the handlers return: the deferred teardown deletes the
// forwarding socket once the readyRead frame has unwound.
QEventLoop teardownLoop;
QEventLoop loop;
QObject::connect(&primary, &SingleInstanceManager::filesReceived, &primary, [&](const QStringList &files) {
++handlerCalls;
// The payload must reach the handlers while the forwarding socket is
// still connected: deferring the emit would suppress the modal "Join
// game?" confirmation this hand-off exists to show.
auto *connection = server->findChild<QLocalSocket *>();
socketConnectedWhenHandled = connection != nullptr && connection->state() == QLocalSocket::ConnectedState;
// Observe the socket's destruction rather than polling for it. `destroyed`
// is emitted just before the object goes away, so this stays correct
// however the teardown ends up deleting it, and `&primary` keeps the
// observer alive even once the socket itself is gone.
if (connection != nullptr) {
QObject::connect(connection, &QObject::destroyed, &primary, [&] {
socketReleased = true;
teardownLoop.quit();
});
}
// The primary acknowledges the payload before handling it, so that a
// sender waiting on the acknowledgment never mistakes a busy primary
// for a dead one.
sender.waitForReadyRead(HANDSHAKE_TIMEOUT_MS);
acknowledgment = sender.readAll();
// Stand in for the modal "Join game?" confirmation: the sending instance
// goes away while this nested loop is running, which is what used to
// destroy the socket underneath this readyRead frame.
sender.abort();
QEventLoop modalDialog;
QTimer::singleShot(MODAL_DIALOG_MS, &modalDialog, &QEventLoop::quit);
modalDialog.exec();
handled = files;
loop.quit();
});
sender.connectToServer(server->serverName());
ASSERT_TRUE(sender.waitForConnected(HANDSHAKE_TIMEOUT_MS));
sender.write(encodePayload(PAYLOAD));
sender.flush();
QTimer::singleShot(HANDSHAKE_TIMEOUT_MS, &loop, &QEventLoop::quit);
loop.exec();
EXPECT_EQ(1, handlerCalls) << "the payload must be handed to the handlers exactly once";
EXPECT_EQ(PAYLOAD, handled);
EXPECT_TRUE(socketConnectedWhenHandled) << "the payload was not handled from inside the readyRead emission";
EXPECT_FALSE(acknowledgment.isEmpty()) << "the primary never acknowledged the payload";
// The deferred teardown must not be skipped, and must not delete the socket
// from inside the nested loop above: either would show up here as a crash or
// as a socket that outlives the hand-off and leaks a connection per payload.
QTimer::singleShot(HANDSHAKE_TIMEOUT_MS, &teardownLoop, &QEventLoop::quit);
teardownLoop.exec();
EXPECT_TRUE(socketReleased) << "the forwarding socket was never released after the hand-off";
}
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}