diff --git a/.ci/compile.sh b/.ci/compile.sh
index 63c027473..9be8ba741 100755
--- a/.ci/compile.sh
+++ b/.ci/compile.sh
@@ -311,16 +311,24 @@ if [[ $RUNNER_OS == macOS ]]; then
if [[ $MAKE_PACKAGE ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522
- # have hdiutil repeat the command 10 times in hope of success
+ # have hdiutil repeat the command 10 times in hope of success.
+ # The wrapper has to exit non-zero once it gives up: CPack reads that status to
+ # decide whether the disk image was created, so a zero exit turns a failed hdiutil
+ # into a much later, misleading "Problem copying the package" error that hides
+ # hdiutil's own message.
hdiutil_script="/tmp/hdiutil.sh"
# shellcheck disable=SC2016
echo '#!/bin/bash
+ attempts=10
i=0
- while ! hdiutil "$@"; do
- if (( ++i >= 10 )); then
- echo "Error: hdiutil failed $i times!" >&2
- break
+ while true; do
+ hdiutil "$@" && exit 0
+ rc=$?
+ if (( ++i >= attempts )); then
+ echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
+ exit "$rc"
fi
+ echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
sleep 1
done' >"$hdiutil_script"
chmod +x "$hdiutil_script"
@@ -459,6 +467,206 @@ if [[ $MAKE_COVERAGE ]]; then
echo "::endgroup::"
fi
+if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then
+ echo "::group::Probe the installer's holder lookup"
+ # The working directory is still the build dir here: the package steps below are what
+ # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name
+ # build/build. Both paths are relative to the dir the probe was built into.
+ probe="./installer-holder-probe.exe"
+ if [[ ! -f $probe ]]; then
+ echo "::error file=$0::installer-holder-probe.exe was not built"
+ exit 1
+ fi
+ # The probe exists to name a holder that a process-and-module scan cannot see: one
+ # that has a file open without having loaded it. Holding a temporary DLL with
+ # FileShare.None is exactly that case, so this asserts the reason the probe was
+ # added instead of only that the Restart Manager answers at all. It needs no NSIS
+ # and no packaging, so it runs on every Windows build that gets this far.
+ # The holder is a process of its own: the probe never names the process that started it,
+ # which is what keeps an in-place uninstaller from being named as a holder of the
+ # directory it is emptying. The same DLL held by the script that starts the probe
+ # therefore has to come back as nothing, and that is the second case below.
+ probe_check="./holder-probe-check.ps1"
+ cat >"$probe_check" <<'POWERSHELL'
+$ErrorActionPreference = "Stop"
+$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID"
+Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
+New-Item -ItemType Directory -Force $dir | Out-Null
+$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path
+
+function Invoke-Probe([string] $guardDir) {
+ $start = New-Object System.Diagnostics.ProcessStartInfo
+ $start.FileName = $probe
+ $start.UseShellExecute = $false
+ $start.RedirectStandardOutput = $true
+ $start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir
+ $process = [System.Diagnostics.Process]::Start($start)
+ $read = $process.StandardOutput.ReadToEndAsync()
+ if (-not $process.WaitForExit(20000)) {
+ try { $process.Kill() } catch { }
+ throw "the holder probe did not finish within 20 s for $guardDir"
+ }
+ $output = $read.Result
+ $code = $process.ExitCode
+ $process.Dispose()
+ return @{ Code = $code; Output = $output }
+}
+
+function Wait-UntilLocked([string] $path) {
+ $deadline = (Get-Date).AddSeconds(15)
+ while ((Get-Date) -lt $deadline) {
+ try {
+ $open = [IO.File]::Open($path, "Open", "ReadWrite", "None")
+ $open.Dispose()
+ } catch {
+ return
+ }
+ Start-Sleep -Milliseconds 100
+ }
+ throw "the holder never locked $path"
+}
+
+try {
+ $file = Join-Path $dir "locked.dll"
+ [IO.File]::WriteAllText($file, "x")
+
+ # The holder is a process of its own, not this script: the probe never names the process
+ # that started it (the case below asserts that), so a file held here would be left out on
+ # purpose and the check would pass for the wrong reason.
+ $holderScript = Join-Path $dir "hold.ps1"
+ Set-Content -LiteralPath $holderScript -Value @'
+param([string] $Path)
+$handle = [IO.File]::Open($Path, "Open", "ReadWrite", "None")
+Start-Sleep -Seconds 120
+'@
+ $powershell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
+ $holder = Start-Process -FilePath $powershell -PassThru -ArgumentList @(
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$holderScript`"", "-Path", "`"$file`"")
+ try {
+ Wait-UntilLocked $file
+ $locked = Invoke-Probe $dir
+ if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" }
+ if ($locked.Output -notmatch "pid\s+$($holder.Id)\b") {
+ throw "the lookup did not name the process holding the file: $($locked.Output)"
+ }
+ } finally {
+ try { $holder.Kill() } catch { }
+ try { $holder.WaitForExit(5000) | Out-Null } catch { }
+ }
+
+ # The probe is started by the installer, or by an uninstaller that runs in place from the
+ # directory it is emptying, so the asking process holds files there itself. Naming it would
+ # ask the user to close the program that is asking - a question neither the close nor the
+ # end offer can answer, so the guard repeated it until the update was cancelled. A holder
+ # that is only the asking process has to come back as nothing.
+ $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None")
+ try {
+ $self = Invoke-Probe $dir
+ } finally {
+ $handle.Dispose()
+ }
+ if ($self.Code -ne 0) { throw "expected exit 0 when the asking process is the only holder, got $($self.Code) : $($self.Output)" }
+ if ($self.Output -match "pid\s+$PID\b") { throw "the lookup named the process that started it: $($self.Output)" }
+
+ # A payload executable that refuses to be replaced is running, whether or not this probe
+ # may name it: the Restart Manager does not report every mapping, and the image path of a
+ # process under another account cannot be read from here, so both process lookups can
+ # come back empty while the program is still there. Held here by the asking process, so
+ # that it cannot be named - the answer has to be "could not name it", never "nothing is
+ # holding it", which is what sent an update into an uninstall that could only fail
+ # halfway. Exit 3 is the code the guard answers by asking the user to close it and retry.
+ $image = Join-Path $dir "oracle.exe"
+ [IO.File]::WriteAllText($image, "x")
+ $handle = [IO.File]::Open($image, "Open", "ReadWrite", "None")
+ try {
+ $unnameable = Invoke-Probe $dir
+ } finally {
+ $handle.Dispose()
+ }
+ if ($unnameable.Code -ne 3) {
+ throw "expected exit 3 for a payload executable that is in use but cannot be named, got $($unnameable.Code) : $($unnameable.Output)"
+ }
+ if ($unnameable.Output -notmatch "oracle\.exe is in use") {
+ throw "the answer did not name the executable it could not replace: $($unnameable.Output)"
+ }
+
+ $empty = Join-Path $dir "empty"
+ New-Item -ItemType Directory -Force $empty | Out-Null
+ $nothing = Invoke-Probe $empty
+ if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" }
+ # A process running from the directory is a holder even when it has no file open: an
+ # instance holds its payload as mapped images, not as handles, and that is the state
+ # the guard used to miss - it passed, the purge failed, and the install ended in a
+ # warning with no offer to close and try again. The image is a copy of a native
+ # Windows program, so it starts without its usual directory next to it, and it carries
+ # one of the payload's own names because that is what the lookup names.
+ $copy = Join-Path $dir "cockatrice.exe"
+ Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $copy
+ $running = Start-Process -FilePath $copy -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
+ try {
+ Start-Sleep -Milliseconds 1500
+ $byImage = Invoke-Probe $dir
+ } finally {
+ try { $running.Kill() } catch { }
+ # Wait for the image to be released, or the cleanup below leaves the copy behind.
+ try { $running.WaitForExit(5000) | Out-Null } catch { }
+ }
+ if ($byImage.Code -ne 1) { throw "expected exit 1 for a process running from the directory, got $($byImage.Code) : $($byImage.Output)" }
+ if ($byImage.Output -notmatch "cockatrice\.exe \(pid\s+$($running.Id)") {
+ throw "the lookup did not name the process running from the directory: $($byImage.Output)"
+ }
+
+ # A program in a folder below the directory is not an instance of the installation, even
+ # when it carries a payload program's name: the install directory can be a folder the
+ # user keeps other applications in, each in its own subfolder, and naming one of those
+ # asked for a close the probe never sends and an end it refuses to perform, so the guard
+ # could only abort the update. A program that really does hold a payload file is still
+ # named, by the Restart Manager lookup exercised above.
+ $nested = Join-Path $dir "other"
+ New-Item -ItemType Directory -Force $nested | Out-Null
+ $nestedImage = Join-Path $nested "cockatrice.exe"
+ Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $nestedImage
+ $nestedRunning = Start-Process -FilePath $nestedImage -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
+ try {
+ Start-Sleep -Milliseconds 1500
+ $inSubfolder = Invoke-Probe $dir
+ } finally {
+ try { $nestedRunning.Kill() } catch { }
+ try { $nestedRunning.WaitForExit(5000) | Out-Null } catch { }
+ }
+ if ($inSubfolder.Code -ne 0) {
+ throw "expected exit 0 for a program in a folder below the directory, got $($inSubfolder.Code) : $($inSubfolder.Output)"
+ }
+
+ # A program the user keeps in the directory itself is not a payload program either: the
+ # guard ends only what the payload installs, and naming anything else asks for an answer
+ # that cannot free a single file.
+ $foreign = Join-Path $dir "tool.exe"
+ Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $foreign
+ $foreignRunning = Start-Process -FilePath $foreign -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
+ try {
+ Start-Sleep -Milliseconds 1500
+ $ignored = Invoke-Probe $dir
+ } finally {
+ try { $foreignRunning.Kill() } catch { }
+ try { $foreignRunning.WaitForExit(5000) | Out-Null } catch { }
+ }
+ if ($ignored.Code -ne 0) {
+ throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)"
+ }
+ Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, reported a payload executable it could not name as in use instead of free, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install"
+} finally {
+ Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
+}
+POWERSHELL
+ if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then
+ rm -f "$probe_check"
+ exit 1
+ fi
+ rm -f "$probe_check"
+ echo "::endgroup::"
+fi
+
if [[ $MAKE_INSTALL ]]; then
echo "::group::Install"
cmake --build . --target install --config "$BUILDTYPE"
diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index c97b48342..df054ea26 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -101,6 +101,36 @@ jobs:
--title "$release_name" \
--notes-file "$body_path"
+ vcpkg-baseline:
+ name: vcpkg baseline
+ runs-on: ubuntu-slim
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v7
+
+ # vcpkg-tool reads versions/baseline.json out of the commit named by
+ # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
+ # pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by
+ # `git add` in a PR - makes every vcpkg leg fail in configure with "failed to
+ # `git show` versions/baseline.json". That reads like a baseline or network
+ # problem, when the pointer is what has to move. Check the two agree here in
+ # seconds instead of on five build legs.
+ - name: "Check the submodule pointer against the manifest baseline"
+ shell: bash
+ run: |
+ pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)"
+ baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)"
+ if [[ -z "$pointer" || -z "$baseline" ]]; then
+ echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline"
+ exit 1
+ fi
+ if [[ "$pointer" != "$baseline" ]]; then
+ echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline"
+ echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg"
+ exit 1
+ fi
+ echo "vcpkg submodule and builtin-baseline agree on $baseline"
+
build-linux:
strategy:
fail-fast: false
@@ -466,7 +496,7 @@ jobs:
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
+ needs: [configure, vcpkg-baseline]
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
env:
@@ -584,11 +614,17 @@ jobs:
# 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.
+ # always() covers the cache step itself failing: the action's post save still runs
+ # then, and it should report the real error instead of ENOENT. It cannot rescue a
+ # failure before the cache step, since a post step only runs for an action whose
+ # main step did. The fallback keeps this guard from failing when the cache step was
+ # skipped and left no path output.
- name: "Create vcpkg cache directory"
+ if: always()
shell: bash
env:
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
- run: mkdir -p "$CACHE_DIR"
+ run: mkdir -p "${CACHE_DIR:-$GITHUB_WORKSPACE/.vcpkg-cache}"
# Uses environment variables, see compile.sh for more details
- name: "Build Cockatrice"
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 97b87a1d8..8a25fe2c6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -90,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.2)
+project("Cockatrice" VERSION 3.2.0)
# Set release name if not provided via env/cmake var
if(NOT DEFINED GIT_TAG_RELEASENAME)
@@ -250,7 +250,22 @@ message(STATUS "Update Translations: ${UPDATE_TRANSLATIONS}")
include(FindQtRuntime)
+# Reject APIs that were deprecated before the oldest Qt release we support
+# (6.4). Qt 6.5 renamed the switch, so use whichever spelling the found
+# version understands while still compiling everywhere.
+if(Qt6_VERSION VERSION_GREATER_EQUAL 6.5)
+ add_compile_definitions(QT_DISABLE_DEPRECATED_UP_TO=0x060400)
+else()
+ add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400)
+endif()
+
+# Set once here for every target: these variables are inherited by the
+# subdirectories, so the per-directory copies were redundant. Only the
+# variables are set; qt_standard_project_setup would also change the runtime
+# output directories and the install RPATH.
set(CMAKE_AUTOMOC TRUE)
+set(CMAKE_AUTOUIC TRUE)
+set(CMAKE_AUTORCC TRUE)
# Find other needed libraries
find_package(Protobuf CONFIG)
@@ -420,6 +435,9 @@ if(WITH_ORACLE OR WITH_CLIENT)
add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_models ${CMAKE_BINARY_DIR}/libcockatrice_models)
add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_filters ${CMAKE_BINARY_DIR}/libcockatrice_filters)
endif()
+if(WITH_CLIENT)
+ add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_game ${CMAKE_BINARY_DIR}/libcockatrice_game)
+endif()
if(WITH_SERVER)
add_subdirectory(servatrice)
@@ -431,6 +449,13 @@ if(WITH_CLIENT)
set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
endif()
+if(WIN32 AND WITH_CLIENT)
+ # Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed
+ # (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install
+ # rules and is not part of the client's staged tree.
+ add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe)
+endif()
+
if(WITH_ORACLE)
add_subdirectory(oracle)
set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
@@ -443,5 +468,5 @@ endif()
if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
# Qt6.3+ requires project finalization to support translations
- qt6_finalize_project()
+ qt_finalize_project()
endif()
diff --git a/README.md b/README.md
index f2a170868..fa9272c0f 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,10 @@
---
-To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/)
-To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))
-To help with development ⇢ learn [how to contribute](#contribute-)
+To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/)
+To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))
+To learn about the app and its code ⇢ [read our code documentation](https://cockatrice.github.io/docs/)
+To help with development ⇢ learn [how to contribute](#contribute)
@@ -83,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p
### Code
-To contribute code to the project, please review our [guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
+To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
We maintain two tags for contributors to easier find issues to potentially work on:
- [](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on.
@@ -95,7 +96,7 @@ We'll happily advice on how best to implement a feature, or we can show you wher
You can also have a look at our `Todo List` in our [Code Documentation](https://cockatrice.github.io/docs) or search the repo for [`\todo` comments](https://github.com/search?q=repo%3ACockatrice%2FCockatrice%20%5Ctodo&type=code).
-### Documentation [](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Apush)
+### Documentation [](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Arelease+event%3Aworkflow_dispatch)
There are various places where useful information for different needs are maintained:
- [Official Code Documentation](https://cockatrice.github.io/docs/)
@@ -104,7 +105,8 @@ There are various places where useful information for different needs are mainta
- [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file`
- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md)
-Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage you to improve the documentation by suggesting edits based on this guide.
+Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation.
+We encourage everybody to improve the documentation by suggesting edits based on this guide.
### Translation [](https://explore.transifex.com/cockatrice/cockatrice/)
diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in
index 0ef746430..923aded9d 100644
--- a/cmake/NSIS.template.in
+++ b/cmake/NSIS.template.in
@@ -12,10 +12,24 @@ Var NormalDestDir
Var PortableDestDir
Var PortableMode
Var ReinstallMode
-Var AllowForceClose
Var GuardDir
Var OldUninstallString
Var OldInstallDir
+; What ExecWait reported for the previous version's uninstaller, or "missing" /
+; "launch-failed" when RunOldUninstaller could not start it. Read by WaitForOldPayloadToDisappear: an uninstaller that ran in place
+; (_?=) and failed or was cancelled will not remove anything more, however long we wait.
+Var OldUninstallExit
+; Executable path parsed out of $OldUninstallString by ResolveOldUninstallExe. Used to
+; tell an uninstaller that is missing or cannot be started ("missing" / "launch-failed"
+; in $OldUninstallExit) from one that ran and failed.
+Var OldUninstallExe
+; Set to 1 by RemoveOldPayloadLeftovers only when every leftover it is responsible for is
+; gone; WaitForOldPayloadToDisappear aborts while it is still 0.
+Var OldPayloadRemoved
+; Scratch state of AppendUninstallerLog.
+Var UninstLogHandle
+Var UninstLogLine
+Var UninstLogChar
Var DataBackupDir
Var DataBackupDone
Var DataBackupForDir
@@ -27,9 +41,48 @@ Var StaleFindName
Var DataBackupFindHandle
Var DataBackupFindName
Var IsUpgrade
+; Set together by whichever purge block ran: the verdict RetryDllPurge measured
+; and whether a purge ran at all - a portable install into a directory without
+; portable.dat is neither purge block, and the abort below measures it itself.
+Var StaleRuntimeAfterPurge
+Var DllPurgeRan
Var AbortReason
+; Diagnostics for a failed purge: the holder probe in LogProcessesUsingInstallDir writes
+; $HolderOutput (one log line) and sets $HolderFound when it named anything.
+Var HolderExit
+Var HolderOutput
+Var HolderFound
+; What the last RunHolderProbe call answered: its exit code and its line. Kept
+; apart from $HolderExit/$HolderOutput because the guard only wants to know
+; whether the payload is free, while the abort dialog reports what survived.
+Var GuardExit
+Var GuardOutput
+; How many times the guard has asked the payload programs to close by itself. Bounded, and
+; only repeated while the listing changed between rounds (see GuardAppsNotRunning).
+Var GuardCloseRounds
+; The listing the last automatic close was sent against, so a second round is spent only
+; when something actually exited and the rest is worth asking again. Empty before round one.
+Var GuardLastHolders
+; How many times the user asked to check again after closing programs by hand (logged only).
+Var GuardRecheckCount
+; The question CockEndPrompt/CockEndRecheckPrompt assemble: fixed sentences around the
+; probe's line, and the result has to stay inside the characters one NSIS string holds.
+Var GuardPrompt
Var WaitPid
Var LauncherExited
+; How long one WaitForSingleObject on the launcher's handle waits before asking the user
+; (see WaitForLauncherToExit). A variable because the first slice is short and the ones
+; after the first question are not.
+Var WaitSlice
+; Whether that handle carries PROCESS_TERMINATE as well as SYNCHRONIZE, and can therefore
+; be ended from the answer the dialog offers. Cleared when only the synchronize right could
+; be had (see WaitForLauncherToExit).
+Var WaitCanEnd
+; How far the launcher wait got with a launcher that has no window to ask: 0 before the
+; first round, 1 after the grace it was given, 2 after it was ended. A launcher whose
+; window is gone has answered its own close and is stuck in shutdown, so the dialog cannot
+; reach it and the wait may end it instead of asking the user (see WaitForLauncherToExit).
+Var WaitWindowless
Var InstallLogPath
Var LogHandle
Var LogMessage
@@ -94,7 +147,10 @@ Page Custom PortableModePageCreate PortableModePageLeave
Call CockLogWrite
!macroend
-Function CockLogWrite
+; The body is shared because the uninstaller needs its own copy: un.* functions are
+; compiled into a separate binary, and inside one a Call may only name an un. function,
+; so CockLog's Call to CockLogWrite cannot be used there.
+!macro CockLogWriteBody
Pop $LogMessage
FileOpen $LogHandle "$InstallLogPath" a
${If} $LogHandle == ""
@@ -106,6 +162,20 @@ Function CockLogWrite
FileSeek $LogHandle 0 END
FileWrite $LogHandle "$LogMessage$\r$\n"
FileClose $LogHandle
+!macroend
+
+Function CockLogWrite
+ !insertmacro CockLogWriteBody
+FunctionEnd
+
+; CockLog for un.* code: same lines, prefixed call target.
+!macro CockLogUn text
+ Push "${text}"
+ Call un.CockLogWrite
+!macroend
+
+Function un.CockLogWrite
+ !insertmacro CockLogWriteBody
FunctionEnd
; Logs every file matching $INSTDIR\. Used to record the DLL inventory
@@ -148,6 +218,11 @@ StrCpy $LooseDeckNoticeShown ""
; No old install location resolved yet; Backup/Restore fall back to $INSTDIR
; when this is empty (the /R path where $INSTDIR already is the old dir).
StrCpy $OldInstallDir ""
+StrCpy $OldUninstallExit ""
+; No purge has run and nothing has been measured yet. The section sets both
+; after a purge, or measures the directory itself when neither purge applies.
+StrCpy $StaleRuntimeAfterPurge 0
+StrCpy $DllPurgeRan 0
; Start each run from a clean file so the log that gets sent back is the last
; run, not every past run glued together.
FileOpen $LogHandle "$InstallLogPath" w
@@ -214,10 +289,10 @@ ${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.
+; alive from the files it still has open - a guess that can come back "nothing
+; is holding them" while the client 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 ""
@@ -255,18 +330,16 @@ ${Else}
${EndIf}
; 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.
+; replaced or deleted. It goes first because a real handle on that process is
+; authority: the holder probe that follows asks the same question indirectly, by
+; looking at which files are still open.
;
; 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.
+; file locks) before the previous version is uninstalled. The probe names them
+; and the user is asked to close them; nothing is force-stopped without that
+; answer, because Cockatrice may hold an unsaved deck and Oracle writes the card
+; database in place. On /R the old version is about to be removed, so an answer
+; the probe cannot give cancels the upgrade before it starts rather than after it.
; 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.
@@ -290,6 +363,14 @@ ${If} ${NSIS_IS_64_BIT} == 1
SetRegView 64
${EndIf}
+; The uninstaller logs to its own file: the installer truncates its log on every run, so
+; sharing one would mean either side erasing the other's account of a failed upgrade.
+StrCpy $InstallLogPath "$TEMP\Cockatrice-uninstaller.log"
+FileOpen $LogHandle "$InstallLogPath" w
+FileClose $LogHandle
+!insertmacro CockLogUn "=================================================================="
+!insertmacro CockLogUn "uninstaller start: @CPACK_PACKAGE_NAME@ @CPACK_PACKAGE_VERSION@ dir=$INSTDIR"
+
; Ensure no application instance is still running before removing files.
Call un.EnsureAppsNotRunning
@@ -320,6 +401,61 @@ ${If} $ReinstallMode = 1
${EndIf}
FunctionEnd
+; Sets $OldUninstallExe to the executable named in $OldUninstallString (everything up to
+; and including the first ".exe", without a leading quote), or "" when there is none.
+Function ResolveOldUninstallExe
+ Push $R7
+ Push $R8
+ StrLen $R8 $OldUninstallString
+ StrCpy $R7 0
+ roue_loop:
+ ${If} $R7 >= $R8
+ StrCpy $OldUninstallExe ""
+ Goto roue_out
+ ${EndIf}
+ StrCpy $OldUninstallExe $OldUninstallString 4 $R7
+ ${If} $OldUninstallExe == ".exe"
+ IntOp $R7 $R7 + 4
+ StrCpy $OldUninstallExe $OldUninstallString $R7
+ StrCpy $R8 $OldUninstallExe 1
+ ${If} $R8 == '"'
+ StrCpy $OldUninstallExe $OldUninstallExe "" 1
+ ${EndIf}
+ Goto roue_out
+ ${EndIf}
+ IntOp $R7 $R7 + 1
+ Goto roue_loop
+ roue_out:
+ Pop $R8
+ Pop $R7
+FunctionEnd
+
+; Runs "$OldUninstallString _?=$OldInstallDir" in place and sets $OldUninstallExit:
+; the uninstaller's own exit code when it ran, "missing" when the executable the
+; registry names does not exist, "launch-failed" when ExecWait could not start it.
+; The last two used to leave the code empty, which WaitForOldPayloadToDisappear read
+; as a cancelled uninstall and aborted on, although nobody had cancelled anything.
+; Uses $R5 for the exit code, as the callers did before.
+Function RunOldUninstaller
+ Call ResolveOldUninstallExe
+ StrCpy $R5 ""
+ ${If} $OldUninstallExe == ""
+ ${OrIfNot} ${FileExists} "$OldUninstallExe"
+ StrCpy $OldUninstallExit "missing"
+ !insertmacro CockLog "old uninstall: uninstaller '$OldUninstallExe' does not exist (command '$OldUninstallString')"
+ Return
+ ${EndIf}
+ ClearErrors
+ ExecWait '$OldUninstallString _?=$OldInstallDir' $R5
+ ${If} ${Errors}
+ ${OrIf} $R5 == ""
+ StrCpy $OldUninstallExit "launch-failed"
+ !insertmacro CockLog "old uninstall: could not start '$OldUninstallString _?=$OldInstallDir'"
+ Return
+ ${EndIf}
+ StrCpy $OldUninstallExit $R5
+FunctionEnd
+
Function AutoUninstallIfNeeded
SetShellVarContext all
@@ -344,7 +480,10 @@ StrCpy $OldInstallDir $INSTDIR
StrCmp $R0 "" done32
DetailPrint "Removing previous version (32-bit)..."
Call BackupUserDataBeforeUninstall
-ExecWait '$R0 _?=$OldInstallDir'
+StrCpy $OldUninstallString $R0
+Call RunOldUninstaller
+!insertmacro CockLog "uninstall (32-bit): exit=$OldUninstallExit"
+Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done32:
@@ -359,7 +498,10 @@ ${If} ${RunningX64}
StrCmp $R0 "" done64
DetailPrint "Removing previous version (64-bit)..."
Call BackupUserDataBeforeUninstall
- ExecWait '$R0 _?=$OldInstallDir'
+ StrCpy $OldUninstallString $R0
+ Call RunOldUninstaller
+ !insertmacro CockLog "uninstall (64-bit): exit=$OldUninstallExit"
+ Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done64:
@@ -367,6 +509,192 @@ ${EndIf}
FunctionEnd
+; Waits for the previous installation's payload to be gone, so the sections below never
+; write into a directory an old uninstaller is still emptying.
+;
+; ExecWait only waits for as long as the uninstaller runs in place: an NSIS uninstaller
+; that did not get a usable _?= copies itself to %TEMP%, starts that copy and exits, so
+; ExecWait returns at once while the copy keeps deleting - and it finishes after this
+; window has closed, removing the version that was just installed. Waiting for
+; cockatrice.exe to disappear covers both cases, and stops with a message instead of
+;
+; When the payload is still there after the wait, $OldUninstallExit decides: a non-zero
+; code means the uninstaller was cancelled or failed, and the install aborts saying so.
+; A zero code means it finished but skipped files someone was holding (NSIS Delete does
+; that silently), so RemoveOldPayloadLeftovers frees and removes them, and the install
+; only aborts - naming the holders - when even that fails.
+; installing over a directory that is still being emptied.
+Function WaitForOldPayloadToDisappear
+${If} $OldInstallDir == ""
+ Return
+${EndIf}
+; The registered uninstaller could not be run at all (missing, or ExecWait could not
+; start it): nothing is going to empty the directory, so waiting is pointless and
+; aborting as "cancelled" was wrong. Remove the previous payload the same way the
+; skipped-files recovery below does, and abort - naming why - only if that fails.
+${If} $OldUninstallExit == "missing"
+${OrIf} $OldUninstallExit == "launch-failed"
+ ${IfNot} ${FileExists} "$OldInstallDir\*.*"
+ !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit and $OldInstallDir is already gone"
+ Return
+ ${EndIf}
+ !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit ('$OldUninstallString'); removing $OldInstallDir payload directly"
+ Call AppendUninstallerLog
+ Call RemoveOldPayloadLeftovers
+ ${If} $OldPayloadRemoved != "1"
+ StrCpy $AbortReason \
+ "The previous version in $OldInstallDir could not be removed: its uninstaller ($OldUninstallExe) could not be run ($OldUninstallExit), and removing the files directly failed.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again."
+ Call RestoreUserDataAfterUninstall
+ Call AbortInstall
+ ${EndIf}
+ !insertmacro CockLog "old uninstall: continuing after removing the payload of an uninstaller that could not run"
+ Return
+${EndIf}
+StrCpy $R2 0
+old_uninstall_wait:
+IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone
+IntOp $R2 $R2 + 1
+${If} $R2 > 60
+ !insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s (uninstaller exit=$OldUninstallExit)"
+ ; The uninstaller's own account of what it skipped or why it stopped lives in its
+ ; separate log; copy it here so the report sent from AbortInstall carries it.
+ Call AppendUninstallerLog
+ ${If} $OldUninstallExit != "0"
+ ; Cancelled or failed with a non-zero exit code (an uninstaller that could not be
+ ; started is handled above): the old version was not meant to be removed, so
+ ; nothing is deleted on its behalf.
+ StrCpy $AbortReason \
+ "The previous version in $OldInstallDir is still there because its uninstaller was cancelled or failed (exit code '$OldUninstallExit').$\r$\n$\r$\nClose anything still using those files and run this installer again."
+ ; The backup was taken before the uninstaller ran; an abort must not leave the
+ ; only copy of the user data in %TEMP%, where the next run clears it.
+ Call RestoreUserDataAfterUninstall
+ Call AbortInstall
+ ${EndIf}
+ ; The uninstaller finished but skipped files - a running instance or a scanner holding
+ ; them, which NSIS Delete passes over silently. Free them and remove what is left.
+ Call RemoveOldPayloadLeftovers
+ ; The removal reports success only when every payload executable is gone and the
+ ; DLL/Plugins purge came back clean; cockatrice.exe alone would hide a locked
+ ; oracle.exe or a stale DLL the new payload is about to be written over.
+ ${If} $OldPayloadRemoved != "1"
+ StrCpy $AbortReason \
+ "The previous version in $OldInstallDir could not be fully removed after its uninstaller ran.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again."
+ Call RestoreUserDataAfterUninstall
+ Call AbortInstall
+ ${EndIf}
+ ; The payload is gone and ExecWait already waited for the uninstaller; keep the
+ ; short settle the other paths use, then hand back to the caller's restore.
+ Sleep 1000
+ !insertmacro CockLog "old uninstall: continuing after removing the leftovers the uninstaller skipped"
+ Return
+${EndIf}
+Sleep 500
+Goto old_uninstall_wait
+old_uninstall_gone:
+${If} $R2 > 0
+ ; ExecWait returned while the old payload was still there: that uninstaller
+ ; copied itself to %TEMP%, and the copy is still working through its file list.
+ ; cockatrice.exe is gone before the rest of it, so let the copy settle before
+ ; the sections below start writing files it would otherwise remove.
+ Sleep 1000
+ !insertmacro CockLog "old uninstall: ExecWait returned early, settled 1 s after $R2 * 500 ms"
+${Else}
+ !insertmacro CockLog "old uninstall: $OldInstallDir was already clear when ExecWait returned"
+${EndIf}
+Return
+FunctionEnd
+
+; Called when the old uninstaller exited successfully but left its payload behind, or
+; could not be run at all (missing or not startable) and the payload is still there.
+; Frees the files the same way the guard does (close, then offer to end), deletes what
+; the uninstaller skipped, and on failure records which files survived and who holds
+; them in $HolderOutput, for the abort message. Sets $OldPayloadRemoved to 1 only when
+; every payload executable is gone and the DLL/Plugins purge is clean.
+Function RemoveOldPayloadLeftovers
+ !insertmacro CockLog "old uninstall: removing leftovers of $OldInstallDir"
+ StrCpy $OldPayloadRemoved 0
+ StrCpy $HolderOutput "nothing could be named"
+ StrCpy $GuardDir $OldInstallDir
+ Call GuardAppsNotRunning
+ ; The purge helpers work on $INSTDIR; point them at the old directory for the
+ ; duration (on /R the two are the same anyway).
+ Push $INSTDIR
+ StrCpy $INSTDIR $OldInstallDir
+ StrCpy $R2 0
+ rop_loop:
+ SetFileAttributes "$INSTDIR\cockatrice.exe" NORMAL
+ SetFileAttributes "$INSTDIR\oracle.exe" NORMAL
+ SetFileAttributes "$INSTDIR\servatrice.exe" NORMAL
+ Delete "$INSTDIR\cockatrice.exe"
+ Delete "$INSTDIR\oracle.exe"
+ Delete "$INSTDIR\servatrice.exe"
+ Delete "$INSTDIR\qt.conf"
+ Call RetryDllPurge
+ ; $R0 is RetryDllPurge's verdict (1 = a DLL or the Plugins directory survived).
+ ; Every payload executable has to be gone as well: a locked oracle.exe or
+ ; servatrice.exe is just as fatal to the install that follows as cockatrice.exe.
+ IfFileExists "$INSTDIR\cockatrice.exe" rop_retry
+ IfFileExists "$INSTDIR\oracle.exe" rop_retry
+ IfFileExists "$INSTDIR\servatrice.exe" rop_retry
+ ${If} $R0 == 0
+ Goto rop_done
+ ${EndIf}
+ rop_retry:
+ IntOp $R2 $R2 + 1
+ ${If} $R2 < 3
+ !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir survived removal attempt $R2, retrying in 2 s"
+ Sleep 2000
+ Goto rop_loop
+ ${EndIf}
+ !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir still present after 3 removal attempts"
+ !insertmacro CockLogListing "old-leftovers" "*.*"
+ ; Per-file Windows codes for the DLLs plus the holder probe's line ($HolderOutput).
+ Call LogStaleRuntimeDiagnostics
+ Pop $INSTDIR
+ Return
+ rop_done:
+ StrCpy $OldPayloadRemoved 1
+ ; Only now that the recovery has succeeded: deleting the old uninstaller on a
+ ; failed attempt would leave a directory whose UninstallString points at nothing,
+ ; so no later run could remove it. WriteUninstaller recreates it on success anyway.
+ SetFileAttributes "$INSTDIR\uninstall.exe" NORMAL
+ Delete "$INSTDIR\uninstall.exe"
+ !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir removed"
+ !insertmacro CockLogListing "old-leftovers-after-removal" "*.*"
+ Pop $INSTDIR
+FunctionEnd
+
+; Copies the old uninstaller's log ($TEMP\Cockatrice-uninstaller.log) into this
+; installer's log, so the report AbortInstall sends shows what the uninstaller did.
+; Uninstallers older than this log simply have no file; that is logged too.
+Function AppendUninstallerLog
+ ClearErrors
+ FileOpen $UninstLogHandle "$TEMP\Cockatrice-uninstaller.log" r
+ ${If} ${Errors}
+ ${OrIf} $UninstLogHandle == ""
+ !insertmacro CockLog "uninstaller log: not found at $TEMP\Cockatrice-uninstaller.log"
+ Return
+ ${EndIf}
+ !insertmacro CockLog "uninstaller log begin ----------------------------------------"
+ aul_loop:
+ ClearErrors
+ FileRead $UninstLogHandle $UninstLogLine
+ IfErrors aul_done
+ ; Strip the line break FileRead keeps, so CockLog does not double it.
+ aul_trim:
+ StrCpy $R9 $UninstLogLine 1 -1
+ ${If} $R9 == "$\n"
+ ${OrIf} $R9 == "$\r"
+ StrCpy $UninstLogLine $UninstLogLine -1
+ Goto aul_trim
+ ${EndIf}
+ !insertmacro CockLog "uninstaller: $UninstLogLine"
+ Goto aul_loop
+ aul_done:
+ FileClose $UninstLogHandle
+ !insertmacro CockLog "uninstaller log end ------------------------------------------"
+FunctionEnd
+
; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall
;
; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR".
@@ -668,222 +996,337 @@ FunctionEnd
; "The procedure entry point X could not be located in the dynamic link
; library ...Qt6Network.dll" on the next start.
;
-; On a silent update (/R /S) matching processes are asked to close gracefully
-; (WM_CLOSE) and waited for up to 60 s. Cockatrice is never force-stopped: it
-; may be holding an unsaved deck or showing a "really leave this game?"
-; confirmation, so if it refuses to close the install is aborted - nothing has
-; been replaced yet, so the update is simply deferred. Oracle and Servatrice
-; are console applications without a main window, so CloseMainWindow() does
-; nothing for them and waiting cannot help; they hold no unsaved user data and
-; are force-stopped by PID once the grace period is over. On interactive
-; installs and uninstalls the user is prompted to close them instead.
+; Both questions this installer has about running processes - "may I replace
+; the payload?" and "what is holding this file I could not delete?" - are
+; answered by tools/installer-holder-probe, a small executable built from this
+; repository and embedded in the installer and the uninstaller alike. It names
+; every process and service that has one of the payload files open or mapped
+; (the Restart Manager, RmRegisterResources/RmGetList) and, separately, every
+; payload program whose image lies in the directory being replaced rather than
+; in a folder below it.
;
-; Processes are matched by image name AND by their executable path living
-; under the guarded directory ($GuardDir, normally $INSTDIR), so unrelated
-; processes that merely share an image name (e.g. the Oracle DB instance
-; "oracle.exe") are never touched.
+; The second lookup is not redundant. Restart Manager answers "who has this
+; file open", and a running instance does not have to have one open: its own
+; image and the modules it loaded are mapped into it rather than opened by it,
+; and a mapped image is not reliably reported. That is the state the failed
+; updates this probe exists for were in - every DLL still on disk, an instance
+; running from the directory, and "nothing is holding them any more" as the
+; answer. A guard built on file handles alone therefore misses exactly the
+; instance it exists to find; it would pass, the purge would fail, and the
+; install would end in a warning with no offer to close and try again.
+;
+; Why not enumerate processes from this script: the NSIS stub is a 32-bit
+; process, so a bare "powershell" started from it is the 32-bit Windows
+; PowerShell, whose Get-Process cannot read the path of a 64-bit process. A
+; guard built on that misses exactly the running instance it exists to find.
+; The probe opens each process with PROCESS_QUERY_LIMITED_INFORMATION and calls
+; QueryFullProcessImageNameW, which has no bitness mismatch, and Restart
+; Manager is a system API rather than a view of this process's architecture.
+;
+; The files that count are the payload the purge removes and the installer
+; replaces: the three executables, the top-level DLLs, qt.conf and everything
+; below Plugins. A document, a log or a screenshot the user happens to have
+; open in the install directory is not part of it and cannot block an upgrade;
+; the image lookup matches on the directory a process runs from, so it does not
+; widen that set.
+;
+; Nothing is force-stopped without the user asking for it: Cockatrice may be holding
+; an unsaved deck or showing a "really leave this game?" confirmation, Oracle writes
+; the card database in place and can be killed mid-write, and Servatrice may be serving
+; a game. A close is a request, made automatically because that is what the user started
+; the installer to do: the probe first asks every instance over its local socket, which
+; reaches a hidden window and an instance sitting on its own confirmation, and falls back
+; to WM_CLOSE for an older client. The automatic close is retried while the set of holders
+; changes, so one instance exiting does not leave the next one unasked. When a close is
+; still not answered the user is told which program is in the way and is offered to force
+; close all three, check again after closing them by hand, or abort - the last resort, with
+; what it costs spelled out in the prompt.
-; usage: set $R2 to the base image name (without extension, e.g. "cockatrice",
-; as accepted by Get-Process -Name), call this, result in $R0
-; (1 = running from $GuardDir, 0 = not running, 2 = could not determine)
-Function IsAppRunning
- ; Every caller has to set $GuardDir; an empty one would make the path check
- ; below match every process that merely shares the image name, which is the
- ; opposite of what this guard is for.
- ${If} $GuardDir == ""
- StrCpy $R0 2
- Return
- ${EndIf}
- ; Pass the directory to guard to PowerShell via an environment variable so
- ; it never has to be embedded into the -Command string: apostrophes in a
- ; path would terminate the command early, and the path in a -like pattern
- ; would treat [ and ] as wildcard characters.
- System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
- ; Exit the process explicitly so nsExec's pop reflects whether a matching
- ; process was found: powershell.exe exits 0 whether or not the pipeline
- ; produced objects. 0 = not running, 1 = running, anything else = could not
- ; be determined (see IsProcessLookupAvailable).
- ; A process is only a match when its path is readable and lives under
- ; $GuardDir. A process we cannot inspect is not one of ours: the updater is
- ; always started by the application it updates, so it runs in the same user
- ; session, and an interactive install is elevated and can read the paths of
- ; the user's own processes. Assuming a match instead would be a hard stop
- ; rather than a fail-closed guard - an Oracle database instance running as
- ; SYSTEM has a process named "oracle" whose path raises Access denied, and
- ; that would make every silent update abort and every interactive install
- ; loop on the Retry prompt with no way out but Cancel.
- nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"'
- Pop $R0
- ${If} $R0 == "0"
- StrCpy $R0 0
- ${ElseIf} $R0 == "1"
- StrCpy $R0 1
- ${Else}
- ; powershell.exe could not be run at all (nsExec pushes the string
- ; "error"), or died with an unexpected code. That is "could not
- ; determine", which has to stay distinguishable from "definitely
- ; running": a state that can never clear would block the installer
- ; forever. IsProcessLookupAvailable warns about that case once.
- StrCpy $R0 2
- ${EndIf}
+; One sentence for every prompt whose Yes ends the whole set, so that answer reads the same
+; wherever the user meets it: this guard before anything is replaced, the uninstaller's, and
+; the recovery prompt after a failed purge.
+!define END_COST "An unsaved deck, a running game or an unwritten setting is lost, Servatrice drops its players, and an Oracle that is mid-write can damage the card database."
+
+; What the user does instead when they say no: the prompt's other answer stops the run, so they
+; are told how to get past the same wall next time. Also used by the abort dialogs, which is
+; where the answer to "why did it stop" ends up.
+!define AFTER_CLOSING_GUIDANCE "Answer the confirmation Cockatrice may be showing - open it from the notification area if it is hidden there - and use Task Manager for a program that would not end."
+
+; The escape hatch for a user who would rather not force-close anything: the same release,
+; downloaded and run by hand once Cockatrice is closed. Every abort dialog shows this and
+; offers to open the page, so an update that cannot free the files still leaves the user a
+; way forward that does not end in Task Manager.
+!define LATEST_RELEASE_URL "https://github.com/Cockatrice/Cockatrice/releases/latest"
+!define MANUAL_UPDATE_GUIDANCE "To update safely instead, close Cockatrice and download the latest installer from:$\r$\n${LATEST_RELEASE_URL}$\r$\nThen run it manually."
+
+; usage: set the variable named by the second argument to the probe's listing, then call
+; this. Sets $GuardPrompt to the question the guard and the recovery path ask.
+;
+; One macro rather than three copies, because the wording is a promise: Yes ends the
+; programs the payload installs - which is exactly the set EndHolders acts on - and anything
+; else the probe named has to be closed by hand, so a listing that includes a scanner or a
+; service hosted elsewhere does not come with an answer that cannot do what it says.
+;
+; Two answers, one of each: the automatic close already ran when this is asked, so Yes is the
+; only thing left that can free the files and No stops the run. The guard itself uses
+; CockEndRecheckPrompt instead, whose No checks again; this one stays for the recovery path,
+; where a retry cannot change anything because the purge was already retried.
+;
+; The assembled string has to fit the characters one NSIS string holds, which is what
+; bounds HOLDER_LINE_CHAR_MAX in tools\installer-holder-probe\holder_line.h: with the
+; longest listing that can arrive it stays inside that budget instead of being cut off at
+; the tail, where the cancel line is.
+;
+; @param header the sentence that introduces the listing, with its trailing colon
+; @param listing the variable holding the probe's line, $GuardOutput or $HolderOutput
+; @param cancelWord what No gives up, "installation" or "uninstallation"
+!macro CockEndPrompt header listing cancelWord
+ StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}"
+ StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}"
+!macroend
+
+; usage: like CockEndPrompt, for a MB_YESNOCANCEL question whose No checks again.
+;
+; The automatic close has already run, so the three answers are what is left: force close
+; the programs the payload installs (Yes), close them by hand and check again (No), or stop
+; the run (Cancel). No used to be the only alternative to ending them, which meant a user who
+; would rather close the instance themselves had to abandon the update and start the
+; installer again. The listing the question is asked with is refreshed on every pass, so the
+; question changes as programs go away instead of repeating itself word for word.
+;
+; The manual-update guidance lives in the abort dialog, which is where a Cancel ends up, not
+; in this prompt. With the longest listing HOLDER_LINE_CHAR_MAX allows, the assembled string
+; stays under 900 characters, inside the 1024 one NSIS string holds, so the Cancel line is
+; never the part that gets cut off.
+;
+; @param header the sentence that introduces the listing, with its trailing colon
+; @param listing the variable holding the probe's line, $GuardOutput
+; @param cancelWord what Cancel gives up, "installation" or "uninstallation"
+!macro CockEndRecheckPrompt header listing cancelWord
+ StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}"
+ StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to close them yourself, then check again - answer the confirmation Cockatrice may be showing, and open it from the notification area if it is hidden there.$\r$\n$\r$\nChoose Cancel to abort the ${cancelWord}."
+!macroend
+
+; Emitted once for the installer and once for the uninstaller: the uninstaller
+; cannot call installer functions, and its own copy of the probe keeps a running
+; 64-bit instance from being missed there too.
+!macro CockHolderProbeFunctions prefix tag
+Function ${prefix}ExtractHolderProbe
+ InitPluginsDir
+ IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_probe_ready_${tag}
+ File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe"
+ ck_probe_ready_${tag}:
FunctionEnd
-; Sets $R0 to 1 if PowerShell can be started at all, 0 if it cannot. The
-; running instance check is built on top of it, so a machine where
-; powershell.exe is missing, blocked by policy or refused by antivirus is
-; reported once instead of being reported as "Cockatrice is running" on every
-; single check.
-Function IsProcessLookupAvailable
- nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"'
- Pop $R0
- ${If} $R0 == "0"
- StrCpy $R0 1
- ${Else}
- StrCpy $R0 0
- ${EndIf}
+; usage: set $GuardDir to the directory to check, then call this. Sets
+; $GuardExit to the probe's exit code and $GuardOutput to its one-line answer.
+; 0 = nothing holds the payload and no payload program runs from the directory, 1 = the
+; line names what does, anything else = the probe could not answer (2 = no
+; directory was given, 3 = no payload program was named but one refuses to be replaced, or
+; the Restart Manager call failed and no payload program runs from the directory either,
+; "timeout" = it did not return in time).
+Function ${prefix}RunHolderProbe
+ Call ${prefix}ExtractHolderProbe
+ ; The directory travels in an environment variable, never on the command
+ ; line: a path with spaces, quotes or a trailing backslash then does not
+ ; have to survive NSIS quoting.
+ System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
+ ; /TIMEOUT because this can run before the first page: a Restart Manager
+ ; that does not answer must not leave the installer with no window and no
+ ; way out.
+ nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"'
+ Pop $GuardExit
+ Pop $GuardOutput
FunctionEnd
-Function CloseMatchingApps
- ; gracefully ask every matching instance to close (sends WM_CLOSE)
- ;
- ; The filter here is deliberately the strict one: a process is only asked to
- ; close when its path is readable and lives under $GuardDir. Treating a
- ; process whose path cannot be read (an unrelated oracle.exe / servatrice.exe,
- ; or one owned by another account running as SYSTEM) as a match would make
- ; the installer close a third party application, and it runs elevated, so
- ; CloseMainWindow() would succeed on it.
+; usage: set $GuardDir to the directory being replaced, then call this. Asks the running
+; Cockatrice to close, and waits for it to exit.
+;
+; The set is what the probe names: the processes holding a file of the version being
+; replaced, plus the payload programs running from the directory itself. The probe
+; closes that set by pid, which is why nothing here has to match an executable path. Only
+; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no
+; window to ask, so both are left alone here and only ended by the explicit answer.
+;
+; Each instance is asked to close over the local socket it listens on, which is the same
+; request as clicking its close button and reaches one whose window is hidden in the
+; notification area or is sitting on its own confirmation. A client from before that socket
+; is asked with WM_CLOSE on its own window instead, and a visible window is brought forward
+; first. Either way the close is answered, not obeyed - Cockatrice asks before it quits while
+; a card database update is running, a game is open or a deck is unsaved - and that question
+; is only useful if it is in front of the installer instead of behind it.
+;
+; The probe waits up to 30 s for the processes it asked to exit, so the check that follows
+; measures a settled directory rather than sleeping for a fixed time. Its line goes into the
+; log as it stands: it names every holder, including the ones left to the user, so a close
+; that did nothing can be told apart from one that was never sent.
+;
+; Never a force-close here: the application decides whether to quit. Ending is a separate
+; answer the guard prompt offers when a close did not free the files, spelled out with what
+; it costs - see EndHolders - and it ends every program the payload installs.
+Function ${prefix}CloseHolders
+ Call ${prefix}ExtractHolderProbe
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
- nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
+ ; The probe waits 30 s itself. The timeout here only bounds a probe that never returns;
+ ; the Restart Manager call inside it is the same one the holder listing makes.
+ nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-holders'
Pop $R3
+ Pop $R4
+ ; Not the CockLog macro: this function exists twice and the uninstaller copy may only
+ ; Call un. functions, so the macro's two lines are spelled with the prefix here.
+ Push "close-holders: probe exit=$R3 [$R4]"
+ Call ${prefix}CockLogWrite
+ DetailPrint "Closing Cockatrice: $R4"
FunctionEnd
-Function ForceCloseApps
- ; stop the matching instances by PID once the grace period is over. Only
- ; used for the console applications ($AllowForceClose), which have no window
- ; to close and hold no unsaved user data. Same strict filter as above.
+; usage: set $GuardDir to the directory being replaced, then call this. Ends the running
+; Cockatrice, Oracle and Servatrice instances the probe names.
+;
+; This is the only place anything is stopped without being asked to stop, so it is only
+; reached after the user picked it from a prompt that says what it costs: a client that is
+; still running may have an unsaved deck, a game or a settings write in flight, Oracle may
+; be part-way through writing the card database, and Servatrice may be serving a game. The
+; probe ends the three programs the payload installs, services included, and nothing else:
+; a scanner or a program the user keeps in the directory is named and left to the user.
+Function ${prefix}EndHolders
+ Call ${prefix}ExtractHolderProbe
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
- nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"'
+ ; The probe waits up to 5 s for what it ended to be gone, so the check that follows
+ ; measures a settled directory instead of racing the processes it just terminated.
+ nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe" --end-holders'
Pop $R3
+ Pop $R4
+ Push "end-holders: probe exit=$R3 [$R4]"
+ Call ${prefix}CockLogWrite
+ DetailPrint "Ending Cockatrice, Oracle and Servatrice: $R4"
FunctionEnd
+!macroend
-Function WaitForAppToClose
- ; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the
- ; base image name (e.g. "cockatrice")
- Call IsAppRunning
- !insertmacro CockLog "guard $R1: state=$R0 (dir=$GuardDir)"
- ; 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 - but it cannot simply be waved through either, see
- ; HandleUnknownAppState.
- ${If} $R0 = 0
+!insertmacro CockHolderProbeFunctions "" install
+!insertmacro CockHolderProbeFunctions "un." uninstall
+
+; usage: set $GuardDir to the directory to guard, then call this.
+;
+; Asks Cockatrice to close once, then asks one question with one answer that can free the
+; payload. The automatic close is the same request clicking the window's close button makes,
+; so the common case - the client was still shutting down - never turns into a question. The
+; prompt is what remains when the close cannot reach everything: Oracle writes the card
+; database in place, Servatrice has no window, and a client that is sitting on its own
+; confirmation needs an answer nobody can give it from here. It offers to end the programs
+; the payload installs - and only those, which the text says - with what that costs (Yes),
+; and its other answer stops the install (No) instead of repeating the same dialog. What
+; survives an end is reported in the abort, never asked about again - ending is the last
+; resort, see EndHolders.
+;
+; An error is always shown, in every mode. A /S install without /R has nobody at
+; the keyboard, but this installer does not promise unattended runs: when an
+; error needs a decision, asking for one is better than silently leaving a
+; half-updated install behind. The happy path has no dialog, so a /S reinstall
+; that has nothing to report stays silent.
+Function GuardAppsNotRunning
+ !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)"
+ StrCpy $GuardCloseRounds 0
+ StrCpy $GuardRecheckCount 0
+ StrCpy $GuardLastHolders ""
+ guard_check:
+ Call RunHolderProbe
+ ${If} $GuardExit == "0"
+ !insertmacro CockLog "guard: nothing is holding the payload"
+ DetailPrint "No running Cockatrice, Oracle or Servatrice instance was found."
Return
${EndIf}
- ${If} $R0 = 2
- Call HandleUnknownAppState
- Return
- ${EndIf}
-
- ${If} ${Silent}
- ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit
- DetailPrint "Closing $R1 ..."
- Call CloseMatchingApps
- ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so
- ; a bare loop counter would not bound the real wait time to 60 s.
- System::Call 'kernel32::GetTickCount()i.s'
- Pop $R8
- ck_wait_loop:
- Sleep 500
- Call IsAppRunning
- ${If} $R0 = 0
- !insertmacro CockLog "guard $R1: closed gracefully"
- DetailPrint "$R1 closed."
+ ${If} $GuardExit == "1"
+ !insertmacro CockLog "guard: still in use by $GuardOutput"
+ ; Close it for the user first. The probe asks every instance over its local socket
+ ; and falls back to WM_CLOSE for an older client, so this is the same request as
+ ; clicking the window's close button: the application still answers its own "really
+ ; quit?" prompt, so nothing is lost. A second round is spent only when the listing
+ ; changed, which means something exited and the rest is worth asking again; repeating
+ ; the same request against the same processes cannot change anything.
+ ${If} $GuardCloseRounds < 2
+ ${AndIf} $GuardOutput != $GuardLastHolders
+ StrCpy $GuardLastHolders $GuardOutput
+ IntOp $GuardCloseRounds $GuardCloseRounds + 1
+ !insertmacro CockLog "guard: asking the payload programs to close (round $GuardCloseRounds)"
+ Call CloseHolders
+ Goto guard_check
+ ${EndIf}
+ DetailPrint "The install directory is in use by: $GuardOutput"
+ ; The close ran and did not free the files. Force-closing them is what the installer
+ ; can do (Yes); closing them by hand is what the user can do (No checks again, with
+ ; the fresh listing); Cancel stops the run and the abort dialog offers the same
+ ; update from the official download page. Without the middle answer a user who did
+ ; not want anything killed had no way forward but to abandon the update.
+ guard_ask:
+ !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation"
+ MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end IDNO guard_recheck
+ Goto guard_cancel
+ guard_recheck:
+ IntOp $GuardRecheckCount $GuardRecheckCount + 1
+ !insertmacro CockLog "guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)"
+ ; A fresh set gets a fresh automatic close, exactly as if the guard had just started:
+ ; what the user closed by hand may have been one instance of several.
+ StrCpy $GuardCloseRounds 0
+ StrCpy $GuardLastHolders ""
+ Goto guard_check
+ guard_cancel:
+ !insertmacro CockLog "guard: cancelled by user after $GuardRecheckCount recheck(s)"
+ StrCpy $AbortReason \
+ "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again."
+ ; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken;
+ ; restoring here keeps this abort from stranding the only copy in %TEMP%.
+ Call RestoreUserDataAfterUninstall
+ Call AbortInstall
+ ; Only reached through Yes: AbortInstall does not return.
+ guard_end:
+ !insertmacro CockLog "guard: ending the payload programs on request, then checking again"
+ Call EndHolders
+ ; Measure once and report, rather than asking the same question again. Everything the
+ ; payload installs was ended, so what survived is a program this installer may not
+ ; open or does not own - another dialog offering to end it again would only repeat
+ ; itself.
+ Call RunHolderProbe
+ ${If} $GuardExit == "0"
+ !insertmacro CockLog "guard: nothing is holding the payload"
Return
${EndIf}
- ${If} $R0 = 2
- Call HandleUnknownAppState
- Return
- ${EndIf}
- System::Call 'kernel32::GetTickCount()i.s'
- Pop $R9
- IntOp $R9 $R9 - $R8
- ${If} $R9 < 60000
- Goto ck_wait_loop
- ${EndIf}
- ; The grace period is over. Cockatrice is never force-stopped: it may be
- ; showing a "really leave this game?" confirmation or holding an unsaved
- ; deck, and nothing has been replaced yet, so aborting just defers the
- ; update and preserves the user's data.
- ${If} $AllowForceClose = 0
- !insertmacro CockLog "guard $R1: still running after 60s, aborting (no force close)"
- DetailPrint "$R1 is still running after 60 s; aborting the update so that no data is lost."
+ !insertmacro CockLog "guard: still in use after ending them ($GuardExit): $GuardOutput"
+ ${If} $GuardExit == "1"
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
+ "The installation was cancelled because these programs still hold files of the previous version and could not be ended:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again."
+ ${Else}
+ StrCpy $AbortReason \
+ "The installation was cancelled because, after ending Cockatrice, Oracle and Servatrice, the installer could not check whether they still hold files of the previous version.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them and run the installer again."
${EndIf}
- ; Oracle and Servatrice are console applications: CloseMainWindow()
- ; returned false for them and waiting longer cannot help. They hold no
- ; unsaved user data, so stop them by PID.
- !insertmacro CockLog "guard $R1: still running after 60s, force-stopping"
- DetailPrint "Force-stopping $R1 ..."
- Call ForceCloseApps
- Sleep 2000
- Call IsAppRunning
- ${If} $R0 = 0
- !insertmacro CockLog "guard $R1: force-stopped"
- DetailPrint "$R1 closed."
- Return
- ${EndIf}
- ${If} $R0 = 2
- Call HandleUnknownAppState
- Return
- ${EndIf}
- !insertmacro CockLog "guard $R1: could not be stopped, aborting"
- DetailPrint "$R1 is still running after 60 s; aborting the update."
- 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 \
- "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \
- IDCANCEL ck_abort_install
- Call IsAppRunning
- ${If} $R0 = 0
- Return
- ${EndIf}
- ${If} $R0 = 2
- Call HandleUnknownAppState
- Return
- ${EndIf}
- Goto ck_wait_prompt
- ck_abort_install:
- Abort
- ${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 RestoreUserDataAfterUninstall
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}
+ ; Not an answer at all: the probe was not configured, the Restart Manager
+ ; call failed and no payload program runs from the directory, a payload
+ ; program refuses to be replaced but no lookup could name it (it may run
+ ; under another account), or the probe timed out. Carrying on from that is a
+ ; decision, so it is asked for rather than assumed - the stale-runtime check
+ ; after the purge still stands between a failed guard and a mismatched
+ ; install, but it only fires after files have been replaced, and on an
+ ; upgrade the launcher asked for nobody is watching the installer's exit
+ ; code. Retry asks the probe again (a Restart Manager that was busy answers
+ ; the second time, and a program the user has closed frees its file), Ignore
+ ; installs anyway, Abort cancels instead of writing a payload next to files
+ ; the guard could not rule out.
+ !insertmacro CockLog "guard: the holder probe could not answer ($GuardExit): $GuardOutput"
+ DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput"
+ MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
+ "This installer could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \
+ IDRETRY guard_check IDABORT guard_unknown_abort
+ !insertmacro CockLog "guard: continuing without an answer from the probe"
+ Return
+ guard_unknown_abort:
+ StrCpy $AbortReason \
+ "The installation was cancelled because it could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again."
+ ; Same reason as the cancel path above: this abort can be reached from the
+ ; leftover-removal guard, after the backup was taken.
+ Call RestoreUserDataAfterUninstall
+ Call AbortInstall
FunctionEnd
Function EnsureAppsNotRunning
@@ -894,103 +1337,155 @@ 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.
+; Every abort goes through here so no failure ends silently: the in-app updater
+; runs this installer silent and detached and never looks at its exit code, and
+; a /S install without /R has nobody watching its exit code either, so a plain
+; Abort would leave the user with no update and no explanation. An error always
+; shows its dialog, in every mode; a silent run stays silent only for as long
+; as nothing goes wrong. The log path is part of the dialog, so a report can be
+; acted on without asking the user to find the file.
Function AbortInstall
!insertmacro CockLog "ABORT: $AbortReason"
!insertmacro CockLog "full installer log: $InstallLogPath"
- ; Show a dialog whenever someone is at the keyboard to see it: an interactive
- ; install, or the in-app updater's /R upgrade. The /R upgrade is silent, but
- ; the client that launched the installer has already quit, so the user is
- ; still there. Only a /S install without /R is attended by a package manager
- ; and must go straight to the exit code.
- ${If} $ReinstallMode = 1
- ${OrIfNot} ${Silent}
- MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath"
- ; Offer to send the installer log to Sentry so aborted installs can be diagnosed. This block already runs
- ; only when someone is at the keyboard (attended install, or the updater's /R upgrade whose launcher has
- ; quit), so a silent or package-manager install never reaches the prompt and its behavior is unchanged.
- ; Only a package built with a baked DSN offers to send, so the prompt can only appear when a send
- ; can actually go out. A preprocessor guard rather than a runtime test: a package built without a
- ; DSN compiles the block out entirely, so it cannot even carry the helper or its baked endpoint.
- ; No /SD here: a /SD would suppress this prompt on the silent /R upgrade, which is exactly the
- ; attended-update case this offer exists for. The runtime gate above (ReinstallMode/Silent) is the
- ; hang protection — a package-manager /S install without /R never reaches this box, matching the
- ; two sibling MessageBox sites in this file.
- !if "@CPACK_SENTRY_ENABLED@" == "1"
- MessageBox MB_YESNO|MB_ICONQUESTION "Send the installer log to the developers to help fix this? The log contains file paths from this machine, which may include your Windows user name." IDYES sentry_send
- !insertmacro CockLog "sentry send: declined by user"
- Goto sentry_done
- sentry_send:
- ; The abort reason is already in the log file (CockLog writes "ABORT: ..." as the
- ; first thing AbortInstall does), so the helper reads it from there. Nothing
- ; reason-shaped crosses the command line, which keeps quoting trivial and needs
- ; no NSIS string functions.
- InitPluginsDir
- ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both are
- ; configure_file'd into the build root, which is three levels up from project.nsi). An
- ; absolute CMake path here carries forward slashes, which makensis does not treat as
- ; absolute and so fails to find the file at package time.
- File "/oname=$PLUGINSDIR\send-installer-log.ps1" "..\..\..\SendInstallerLog.ps1"
- ; The send can take up to the /TIMEOUT below, so put something on the details pane
- ; the moment the user says yes rather than leaving the window blank until it returns.
- DetailPrint "Sending installer log..."
- nsExec::ExecToStack /TIMEOUT=60000 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\send-installer-log.ps1" -LogPath "$InstallLogPath"'
- Pop $SentryResult
- Pop $SentryOutput
- ; $SentryOutput is a single line by construction: the helper flattens its message
- ; and this log file is uploaded verbatim, so a stray newline must not be able to
- ; forge extra lines into the artifact we send.
- !insertmacro CockLog "sentry send: result=$SentryResult output=$SentryOutput"
- ${If} $SentryResult == "0"
- MessageBox MB_OK|MB_ICONINFORMATION "The installer log was sent. Thank you for helping improve Cockatrice!"
- ${Else}
- MessageBox MB_OK|MB_ICONEXCLAMATION "The installer log could not be sent. You can still share it manually; it was written to:$\r$\n$InstallLogPath"
- ${EndIf}
- sentry_done:
- !endif
- ${EndIf}
+ !insertmacro CockLog "manual update: ${LATEST_RELEASE_URL}"
+ ; The reason names what stopped the run; the manual-update paragraph is the way forward
+ ; that does not need this installer to free anything. Shown in every mode, exactly like
+ ; the reason, because a /R upgrade has nobody watching its exit code.
+ MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\n${MANUAL_UPDATE_GUIDANCE}$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath"
+ ; Offer the page as well: copying a URL out of a message box is a step users skip.
+ ; explorer.exe hands the URL to the already-running shell, so the browser opens at the
+ ; user's integrity level rather than as the elevated installer.
+ MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2 "Open the download page now?$\r$\n$\r$\n${LATEST_RELEASE_URL}" IDNO abort_no_link
+ !insertmacro CockLog "manual update: opening the download page"
+ Exec '"$WINDIR\explorer.exe" "${LATEST_RELEASE_URL}"'
+ abort_no_link:
+ ; Offer to send the installer log to Sentry so aborted installs can be diagnosed.
+ ; Only a package built with a baked DSN offers to send, so the prompt can only appear
+ ; when a send can actually go out. A preprocessor guard rather than a runtime test: a
+ ; package built without a DSN compiles the block out entirely, so it cannot even carry
+ ; the helper or its baked endpoint. No /SD: the abort is exactly the case the offer
+ ; exists for, including on the silent /R upgrade.
+ !if "@CPACK_SENTRY_ENABLED@" == "1"
+ MessageBox MB_YESNO|MB_ICONQUESTION "Send the installer log to the developers to help fix this? The log contains file paths from this machine, which may include your Windows user name." IDYES sentry_send
+ !insertmacro CockLog "sentry send: declined by user"
+ Goto sentry_done
+ sentry_send:
+ ; The abort reason is already in the log file (CockLog writes "ABORT: ..." as the
+ ; first thing AbortInstall does), so the helper reads it from there. Nothing
+ ; reason-shaped crosses the command line, which keeps quoting trivial and needs
+ ; no NSIS string functions.
+ InitPluginsDir
+ ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both are
+ ; configure_file'd into the build root, which is three levels up from project.nsi). An
+ ; absolute CMake path here carries forward slashes, which makensis does not treat as
+ ; absolute and so fails to find the file at package time.
+ File "/oname=$PLUGINSDIR\send-installer-log.ps1" "..\..\..\SendInstallerLog.ps1"
+ ; The send can take up to the /TIMEOUT below, so put something on the details pane
+ ; the moment the user says yes rather than leaving the window blank until it returns.
+ DetailPrint "Sending installer log..."
+ nsExec::ExecToStack /TIMEOUT=60000 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\send-installer-log.ps1" -LogPath "$InstallLogPath"'
+ Pop $SentryResult
+ Pop $SentryOutput
+ ; $SentryOutput is a single line by construction: the helper flattens its message
+ ; and this log file is uploaded verbatim, so a stray newline must not be able to
+ ; forge extra lines into the artifact we send.
+ !insertmacro CockLog "sentry send: result=$SentryResult output=$SentryOutput"
+ ${If} $SentryResult == "0"
+ MessageBox MB_OK|MB_ICONINFORMATION "The installer log was sent. Thank you for helping improve Cockatrice!"
+ ${Else}
+ MessageBox MB_OK|MB_ICONEXCLAMATION "The installer log could not be sent. You can still share it manually; it was written to:$\r$\n$InstallLogPath"
+ ${EndIf}
+ sentry_done:
+ !endif
SetErrorLevel 1
Abort
FunctionEnd
+; usage: set $GuardDir and $WaitPid, then call this. Asks the one process the installer
+; waits on to close and sets $R3 to what it could do: 1 = a window exists and was asked to
+; close (or is waiting on a question of its own), 4 = it has no window a user could answer,
+; anything else = the probe could not answer. Only 4 lets the wait act on its own; every
+; other code keeps the user in the decision, because the installer holds a live handle and
+; "the probe did not find it" is not the same answer as "it is gone".
+;
+; The guard's CloseHolders answers the same question for every holder and leaves a
+; windowless instance to the user, which is right there: the prompt names it. The launcher
+; is the one process nobody can be asked about - the in-app updater runs the installer
+; silent and detached, and a client that has already hidden its window has answered its
+; own prompts - so this answer has to say whether there was a window to ask at all.
+Function CloseLauncher
+ Call ExtractHolderProbe
+ System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
+ System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_LAUNCHER_PID", "$WaitPid")'
+ ; The probe waits 30 s itself when it posts a close. The timeout here only bounds a
+ ; probe that never returns; the Restart Manager call the other modes make is not
+ ; reached in this one.
+ nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-launcher'
+ Pop $R3
+ Pop $R4
+ Push "close-launcher: probe exit=$R3 [$R4]"
+ Call CockLogWrite
+ DetailPrint "Closing Cockatrice: $R4"
+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
+; A mapped module cannot be deleted, so an answer of "nothing is holding them"
+; obtained from file handles 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.
+; needs no process enumeration at all, and is unaffected by a process the holder
+; probe cannot inspect.
;
; 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.
+; falls back to the holder probe.
+;
+; A launcher that survives the first grace is asked to close by the probe, and the
+; probe also answers whether there was a window a close could reach. A client hides
+; its window the moment its own close is accepted, so a launcher without one has
+; already answered its own prompts and is stuck in shutdown: no dialog can reach it
+; there, and the wait ends it after one more grace instead of asking the user a
+; question whose "keep waiting" answer can never change anything.
Function WaitForLauncherToExit
StrCpy $LauncherExited 0
+ StrCpy $WaitWindowless 0
!insertmacro CockLog "wait-launcher: pid='$WaitPid'"
${If} $WaitPid == ""
!insertmacro CockLog "wait-launcher: no pid given, falling back to name check"
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.
- ;
; System.dll maps its output registers so that .rN writes $N and .RN writes
; $RN. The caller reads $R0/$R1, so the uppercase .RN form is required; the
; original .r0/.r1 wrote $0/$1 and left $R1 empty, which made the "did not
; exit" test below compare "" against "0" and abort on every /PID= upgrade.
+ ; Reopened on every round below, so a launcher that exited in the meantime answers
+ ; "already exited" instead of being waited on again.
+ ;
+ ; PROCESS_TERMINATE rides along with PROCESS_SYNCHRONIZE so that the "end it"
+ ; answer below acts on this very handle: the pid looked up again after a dialog
+ ; that can stay open as long as the user takes would name whatever process holds
+ ; that pid by then, and a handle cannot be handed to another process.
+ ;
+ ; When those rights are refused - a launcher started by another account, a
+ ; protected process - the wait still has to happen, so the ask is repeated with
+ ; synchronize access alone and $WaitCanEnd records that ending is not on offer.
+ ; Reporting "already exited" instead would be the worse answer: the payload would
+ ; be written over a live process, which is what this wait exists to prevent.
+ ;
+ ; Ten seconds, not the full grace: a client that started an update exits in well
+ ; under a second, so at ten seconds it is either flushing a card database or - far
+ ; more often - sitting on its own "quit anyway?" confirmation with nobody looking at
+ ; it. Silence for a minute is what makes that read as a hung installer. The full
+ ; grace is spent after the first question, so a user who answers Cockatrice's
+ ; confirmation is not asked again while they do it.
+ StrCpy $WaitSlice 10000
+ wait_launcher_open:
ClearErrors
- System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0'
+ StrCpy $WaitCanEnd 1
+ System::Call 'kernel32::OpenProcess(i 0x00100001, i 0, i $WaitPid) p.R0'
; OpenProcess returns a null handle for a process that is already gone. The
; System plugin does not raise the NSIS error flag from a called function's
; return value, so ${Errors} alone never saw that case and the null handle was
@@ -999,27 +1494,118 @@ Function WaitForLauncherToExit
${If} ${Errors}
StrCpy $R0 0
${EndIf}
+ ${If} $R0 == 0
+ StrCpy $WaitCanEnd 0
+ ClearErrors
+ System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0'
+ ${If} ${Errors}
+ StrCpy $R0 0
+ ${EndIf}
+ ${EndIf}
${If} $R0 == 0
; The launcher is gone - OpenProcess cannot open a dead process - which is
- ; exactly the state the wait was for.
+ ; exactly the state the wait was for. A handle refused for want of the terminate
+ ; right is not this case; the synchronize-only ask above covers it.
!insertmacro CockLog "wait-launcher: already exited"
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)'
+ System::Call 'kernel32::WaitForSingleObject(p R0, i $WaitSlice) i.R1'
${If} $R1 != 0
- !insertmacro CockLog "wait-launcher: did not exit within 60s (wait=$R1)"
- DetailPrint "The application that started this installer did not exit within 60 s."
+ ; $R0 is deliberately still open: it is the process that timed out, and the answer
+ ; below has to act on that process and not on whatever $WaitPid names by then.
+ !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1, canEnd=$WaitCanEnd)"
+ DetailPrint "The application that started this installer did not exit in time."
+ ; Ask the launcher itself before asking the user. A client that has already hidden
+ ; its window has answered its own prompts and is stuck in shutdown: there is nothing
+ ; left a dialog could reach, and the "ask it to close again" answer the dialog used
+ ; to offer could only repeat itself until the user cancelled.
+ StrCpy $GuardDir $INSTDIR
+ Call CloseLauncher
+ ; $R3 = what the probe could do (see CloseLauncher), $R4 = its line, already logged.
+ ; A process that answered the close during the probe's own wait is gone by now.
+ System::Call 'kernel32::WaitForSingleObject(p R0, i 0) i.R1'
+ ${If} $R1 == 0
+ System::Call 'kernel32::CloseHandle(p R0)'
+ StrCpy $R0 0
+ Goto wait_launcher_open
+ ${EndIf}
+ ${If} $R3 == "4"
+ ; Nothing a user could answer: the window is gone, so the launcher is past its
+ ; own prompts and stuck in shutdown. It cannot be asked, and it is not going to
+ ; ask the user anything either, so the dialog below would be unanswerable.
+ ${If} $WaitCanEnd == 1
+ ${If} $WaitWindowless == 0
+ ; Hiding the window is what the client does when its close is accepted,
+ ; so what remains is the shutdown it already started: one full grace for
+ ; that, without a question nobody could act on.
+ StrCpy $WaitWindowless 1
+ StrCpy $WaitSlice 60000
+ !insertmacro CockLog "wait-launcher: no window to ask, giving it one more minute"
+ System::Call 'kernel32::CloseHandle(p R0)'
+ StrCpy $R0 0
+ Goto wait_launcher_open
+ ${EndIf}
+ ${If} $WaitWindowless == 1
+ ; It did not use that minute either. Ending it is what the dialog's Yes
+ ; answer would have done, and there is no question left to put to the
+ ; user: the instance lost its window before this round started.
+ StrCpy $WaitWindowless 2
+ !insertmacro CockLog "wait-launcher: still no window to ask; ending the launcher"
+ DetailPrint "The application that started this installer could not be asked to close; ending it."
+ System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R2'
+ !insertmacro CockLog "wait-launcher: ending the launcher returned $R2"
+ System::Call 'kernel32::CloseHandle(p R0)'
+ StrCpy $R0 0
+ Goto wait_launcher_open
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ; A window exists, or the launcher cannot be ended from here: the three answers
+ ; below. "Carry on anyway" is not one of them - a live process has its image and its
+ ; Qt DLLs mapped, so writing the payload over them is the broken install this wait
+ ; exists to prevent. Yes ends the launcher, Cancel cancels, and No keeps waiting:
+ ; the close and the bring-forward just happened above.
+ MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \
+ "Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or an unwritten setting is lost.$\r$\n$\r$\nChoose No to keep waiting, or Cancel to cancel the installation." \
+ IDYES wait_launcher_end IDNO wait_launcher_retry
+ !insertmacro CockLog "wait-launcher: cancelled by user"
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
+ ; Only reached through Yes or No: AbortInstall does not return.
+ wait_launcher_end:
+ ; End it through the handle waited on above, and through nothing else: no pid is
+ ; looked up again, so a process that inherited the number in the meantime - or a
+ ; /PID= that was not the launcher to begin with - cannot be hit by this.
+ !insertmacro CockLog "wait-launcher: ending the launcher on request"
+ ${If} $WaitCanEnd == 0
+ !insertmacro CockLog "wait-launcher: the launcher could not be opened to end it, asking it to close instead"
+ Goto wait_launcher_retry
+ ${EndIf}
+ System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R3'
+ System::Call 'kernel32::CloseHandle(p R0)'
+ StrCpy $R0 0
+ !insertmacro CockLog "wait-launcher: ending the launcher returned $R3"
+ Goto wait_launcher_open
+ wait_launcher_retry:
+ System::Call 'kernel32::CloseHandle(p R0)'
+ StrCpy $R0 0
+ ; The question has been asked and answered once; from here the wait is the full
+ ; grace again, so a slow flush or a confirmation being answered does not turn
+ ; into a stream of prompts. The close itself and the bring-forward already
+ ; happened just before the dialog, so this only waits. The windowless grace starts
+ ; over as well: "keep waiting" must not be answered by ending the process a minute
+ ; later without asking again.
+ StrCpy $WaitWindowless 0
+ StrCpy $WaitSlice 60000
+ Goto wait_launcher_open
${EndIf}
+ System::Call 'kernel32::CloseHandle(p R0)'
+ StrCpy $R0 0
!insertmacro CockLog "wait-launcher: launcher exited"
DetailPrint "The application that started this installer has exited."
StrCpy $LauncherExited 1
@@ -1043,57 +1629,6 @@ Function EnsureOldInstallNotRunning
Call GuardAppsNotRunning
FunctionEnd
-Function GuardAppsNotRunning
- ; usage: set $GuardDir to the directory to guard, then call this
- Call IsProcessLookupAvailable
- !insertmacro CockLog "guard: powershell available=$R0 dir=$GuardDir launcherExited=$LauncherExited"
- ${If} $R0 = 0
- ; Without PowerShell we cannot tell whether a running instance is
- ; holding locks on the files we are about to replace.
- !insertmacro CockLog "guard: powershell unavailable, skipping instance check"
- DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the installation reports a problem."
- ; 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.
- ${If} $ReinstallMode = 1
- ${OrIfNot} ${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"
- StrCpy $R2 "cockatrice"
- ; a GUI application with unsaved user data: graceful close only
- StrCpy $AllowForceClose 0
- Call WaitForAppToClose
- StrCpy $R1 "oracle.exe"
- StrCpy $R2 "oracle"
- StrCpy $AllowForceClose 1
- Call WaitForAppToClose
- StrCpy $R1 "servatrice.exe"
- StrCpy $R2 "servatrice"
- StrCpy $AllowForceClose 1
- Call WaitForAppToClose
-FunctionEnd
-
; Sets $R0 to 1 if $INSTDIR looks like an existing Cockatrice install. Used to
; guard the stale-runtime-DLL purge so a first-time install into a pre-existing
; directory with an unrelated "Plugins" folder is never touched, while an
@@ -1171,142 +1706,351 @@ Function CheckStaleRuntimeMask
${EndIf}
FunctionEnd
-; Uninstaller copies of the same routines (the uninstaller gets its own
-; function set compiled in, it cannot call the installer functions).
-Function un.IsAppRunning
- ; An unset $GuardDir would match every process that merely shares the image
- ; name, see IsAppRunning in the installer.
- ${If} $GuardDir == ""
- StrCpy $R0 2
+; Clears the read-only attribute of everything the purge is about to delete.
+;
+; Delete and RMDir only set the error flag: a read-only file is left on disk
+; untouched, and the retry loop then cannot make progress however long it waits,
+; because the file is not held - it is protected. The abort diagnostics have a
+; branch for exactly that ("read-only, which stops Delete"), so a survivor that
+; is both free and read-only was a dead end until now. This runs on every
+; attempt, so an attribute set again between attempts is cleared as well.
+; SetFileAttributes has no wildcard support, hence the walks.
+Function ClearReadOnlyPurgeTargets
+ FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll"
+ ${If} $StaleFindHandle != ""
+ crp_dll_loop:
+ SetFileAttributes "$INSTDIR\$StaleFindName" NORMAL
+ ClearErrors
+ FindNext $StaleFindHandle $StaleFindName
+ IfErrors 0 crp_dll_loop
+ FindClose $StaleFindHandle
+ ${EndIf}
+ StrCpy $R6 "$INSTDIR\Plugins"
+ Call ClearReadOnlyTree
+FunctionEnd
+
+; usage: set $R6 to a directory. Clears the read-only attribute of every entry
+; below it, so RMDir /r is not stopped by a single file. Saves and restores the
+; registers it works in, because it recurses into itself.
+Function ClearReadOnlyTree
+ Push $R6
+ Push $R7
+ Push $R8
+ FindFirst $R7 $R8 "$R6\*.*"
+ ${If} $R7 != ""
+ crt_loop:
+ ${If} $R8 != "."
+ ${AndIf} $R8 != ".."
+ SetFileAttributes "$R6\$R8" NORMAL
+ ; Only an entry with contents has anything below it that the purge
+ ; would fail on; an empty directory's own attribute is already
+ ; cleared above and there is nothing left to descend into.
+ ${If} ${FileExists} "$R6\$R8\*.*"
+ Push $R6
+ StrCpy $R6 "$R6\$R8"
+ Call ClearReadOnlyTree
+ Pop $R6
+ ${EndIf}
+ ${EndIf}
+ ClearErrors
+ FindNext $R7 $R8
+ IfErrors 0 crt_loop
+ FindClose $R7
+ ${EndIf}
+ Pop $R8
+ Pop $R7
+ Pop $R6
+FunctionEnd
+
+; Removes the Plugins directory and all top-level DLLs from $INSTDIR, then
+; retries up to two more times (2 s apart) when files remain locked, so that
+; transient holders such as antivirus scanners have a chance to release their
+; handles before the installer gives up. Three attempts total. The read-only
+; attribute is cleared before each attempt: a protected file is not a held one
+; and would never clear on its own.
+; Sets $R0 to 0 when the purge is clean, or to 1 when stale DLLs remain
+; after all attempts.
+Function RetryDllPurge
+ StrCpy $R5 0 ; counts retries after the initial attempt
+ rdp_loop:
+ Call ClearReadOnlyPurgeTargets
+ RMDir /r "$INSTDIR\Plugins"
+ Delete "$INSTDIR\*.dll"
+ Call HasStaleRuntimeFiles
+ ; $R0 = 0: all clear, nothing left to do.
+ ${If} $R0 = 0
Return
${EndIf}
- ; Pass the directory to guard to PowerShell via an environment variable so
- ; it never has to be embedded into the -Command string: apostrophes in a
- ; path would terminate the command early, and the path in a -like pattern
- ; would treat [ and ] as wildcard characters.
- System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
- ; Exit the process explicitly so nsExec's pop reflects whether a matching
- ; process was found: powershell.exe exits 0 whether or not the pipeline
- ; produced objects. 0 = not running, 1 = running, anything else = could not
- ; be determined (see un.IsProcessLookupAvailable).
- ; Strict match: a readable path under $GuardDir, same as in the installer.
- nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"'
- Pop $R0
- ${If} $R0 == "0"
- StrCpy $R0 0
- ${ElseIf} $R0 == "1"
- StrCpy $R0 1
+ IntOp $R5 $R5 + 1
+ ; Allow two retries after the initial attempt ($R5 is the retry counter,
+ ; so the loop runs at most 3 times: attempt + retry 1 + retry 2).
+ ${If} $R5 > 2
+ !insertmacro CockLog "RetryDllPurge: stale files remain after 3 attempts, giving up"
+ Return ; $R0 is already 1
+ ${EndIf}
+ !insertmacro CockLog "RetryDllPurge: retry $R5 of 2 after stale purge, sleeping 2 s"
+ Sleep 2000
+ Goto rdp_loop
+FunctionEnd ; RetryDllPurge
+; Turns a Win32 error code into the wording that goes into the log.
+; usage: set $0 to the code, result in $R0.
+;
+; These are Windows' own names for the codes and deliberately nothing more. A
+; code on its own cannot say whether an upgrade was blocked by a running instance
+; or by a permission: deleting a file whose image is mapped by another process
+; surfaces as ACCESS_DENIED, exactly like a file whose ACL denies delete does.
+; LogProcessesUsingInstallDir is what separates the two, which is why it runs
+; right after this and why the two are read together.
+Function GetWin32ErrorText
+ ${If} $0 = 2
+ StrCpy $R0 "already gone"
+ ${ElseIf} $0 = 3
+ StrCpy $R0 "path not found"
+ ${ElseIf} $0 = 5
+ StrCpy $R0 "access denied"
+ ${ElseIf} $0 = 19
+ StrCpy $R0 "write-protected media"
+ ${ElseIf} $0 = 32
+ StrCpy $R0 "sharing violation"
+ ${ElseIf} $0 = 33
+ StrCpy $R0 "lock violation"
+ ${ElseIf} $0 = 1224
+ StrCpy $R0 "file is mapped in memory"
${Else}
- ; powershell.exe could not be run at all (nsExec pushes the string
- ; "error"), or died with an unexpected code. That is "could not
- ; determine", which has to stay distinguishable from "definitely
- ; running": a state that can never clear would block the uninstaller
- ; forever. un.IsProcessLookupAvailable warns about that case once.
- StrCpy $R0 2
+ StrCpy $R0 "windows error"
${EndIf}
FunctionEnd
-Function un.IsProcessLookupAvailable
- nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"'
- Pop $R0
- ${If} $R0 == "0"
- StrCpy $R0 1
- ${Else}
- StrCpy $R0 0
+; Records which runtime files the purge left behind, what Windows said about each
+; one, and which processes are using the install directory.
+;
+; Diagnostic only, and only on the abort path: it deletes nothing, so it can never
+; turn a failed upgrade into a half-removed old install, and it decides nothing -
+; the abort that called it is already decided. It exists because NSIS Delete/RMDir
+; only set the error flag: without this, a report of "files of a previous version
+; survived the purge" said which files were present but never which one was held,
+; and never whether the cause was a lock or a permission.
+;
+; Each survivor is opened for DELETE access and then asked the question DeleteFile
+; asks: SetFileInformationByHandle(FileDispositionInfo) marks it for deletion on
+; close, and the mark is cleared again right away, so nothing is deleted (and if
+; that cancel ever failed, the file would go when the handle closes - a file the
+; purge already tried to delete, and the line says so). That separates the three
+; causes a failed Delete cannot tell apart: another handle that does not share
+; delete refuses the mark, a read-only attribute refuses it, and a file nobody
+; holds accepts it and would have been deleted. The mark is read through ?e, so a
+; refusal reports Windows' own code; clearing LastError first keeps a zero code
+; meaning "accepted". The wording below reports what Windows said and never claims
+; a cause the code cannot prove. Which process is holding a survivor, if any, is
+; what the holders line after the loop answers.
+Function LogStaleRuntimeDiagnostics
+ FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll"
+ ${If} $StaleFindHandle != ""
+ stale_diag_loop:
+ ; ClearErrors tells a plugin failure apart from an API failure: the System
+ ; plugin sets the error flag only when it could not make the call at all,
+ ; in which case it pushes its own message instead of the call's value and
+ ; the pop below must not run.
+ ClearErrors
+ System::Call 'kernel32::SetLastError(i 0)'
+ System::Call 'kernel32::CreateFileW(w "$INSTDIR\$StaleFindName", i 0x00010000, i 0, p 0, i 3, i 0, p 0) p.R1 ?e'
+ ${If} ${Errors}
+ StrCpy $R0 "the delete probe could not be run"
+ ${Else}
+ Pop $R2
+ ${If} $R2 = 0
+ ; The open worked, which says nothing about Delete: a DLL a running
+ ; process has mapped opens for DELETE and still refuses DeleteFile, and
+ ; a read-only file does the same until its attribute is cleared. Ask
+ ; Windows the question the purge actually asks - mark the file for
+ ; deletion on close, then cancel that again. This is the same request
+ ; DeleteFile makes on a temporary handle, so its answer says whether a
+ ; delete would succeed now.
+ ClearErrors
+ System::Call '*(&i1 1) p.R4'
+ System::Call 'kernel32::SetFileInformationByHandle(p R1, i 4, p R4, i 1) i.R5 ?e'
+ ${If} ${Errors}
+ StrCpy $R0 "a delete-access open now succeeds, but Windows could not be asked whether it would delete it"
+ ${Else}
+ Pop $R6
+ ${If} $R5 != 0
+ ; Accepted: cancel the mark again, so this stays a question and
+ ; does not turn into the deletion it is asking about. A cancel
+ ; that fails leaves the file to go when the handle closes, which
+ ; is what the purge wanted anyway; the line says so.
+ ClearErrors
+ System::Call '*(&i1 0) p.R4'
+ System::Call 'kernel32::SetFileInformationByHandle(p R1, i 4, p R4, i 1) i.R5 ?e'
+ ${If} ${Errors}
+ StrCpy $R0 "a delete would succeed now, but the check could not undo the mark it set"
+ ${Else}
+ Pop $R6
+ ${If} $R5 != 0
+ StrCpy $R0 "a delete would succeed now - the earlier failure was transient"
+ ${Else}
+ StrCpy $R0 "a delete would succeed now, and it is marked for deletion from here on"
+ ${EndIf}
+ ${EndIf}
+ ${Else}
+ StrCpy $0 $R6
+ Call GetWin32ErrorText
+ StrCpy $R0 "a delete-access open now succeeds, but Windows refused to mark it for deletion: win32 error $R6: $R0"
+ ${EndIf}
+ ${EndIf}
+ System::Call 'kernel32::CloseHandle(p R1)'
+ ; The read-only attribute is a cause on its own, and the purge clears it
+ ; before every attempt, so a survivor that is read-only had it set again.
+ System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3'
+ ${If} $R3 != -1
+ IntOp $R3 $R3 & 1
+ ${If} $R3 = 1
+ StrCpy $R0 "$R0; it is read-only again"
+ ${EndIf}
+ ${EndIf}
+ ${Else}
+ StrCpy $0 $R2
+ Call GetWin32ErrorText
+ StrCpy $R0 "win32 error $R2: $R0"
+ ${EndIf}
+ ${EndIf}
+ !insertmacro CockLog "stale-runtime: '$StaleFindName' is still present: $R0"
+ ClearErrors
+ FindNext $StaleFindHandle $StaleFindName
+ IfErrors 0 stale_diag_loop
+ FindClose $StaleFindHandle
${EndIf}
+ Call LogProcessesUsingInstallDir
FunctionEnd
-Function un.CloseMatchingApps
- ; gracefully ask every matching instance to close (sends WM_CLOSE)
- ;
- ; Strict filter, same as in the installer: never close a process whose path
- ; cannot be read, it is not necessarily one of ours.
- System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
- nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
- Pop $R3
+; Names the applications and services that still have the payload open or mapped.
+; Writes one line to the log and sets $HolderFound (1 when anything was named), so the
+; abort message can repeat that line to the user.
+;
+; The lookup is tools/installer-holder-probe, the same executable the guard runs before
+; anything is replaced; here it answers the other question, about files a purge could not
+; remove. It asks the Restart Manager (RmRegisterResources/RmGetList), which names a
+; process holding a plain handle on a file - an antivirus scanner - and names services as
+; well as user programs, and it adds every payload program running from the directory
+; itself - not one in a folder below it, which is another application the user installed
+; there - because a running instance holds its payload as mapped images rather than as open
+; handles and the Restart Manager does not reliably report those. Its remaining blind spot
+; is a handle taken in kernel mode, where there is no process to name; a payload executable
+; among the files then still refuses to be replaced, and the probe answers with that file
+; instead of naming nothing.
+;
+; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard
+; uses, so a path with spaces or apostrophes never has to be embedded in a command line.
+Function LogProcessesUsingInstallDir
+ StrCpy $HolderExit 0
+ StrCpy $HolderOutput ""
+ StrCpy $HolderFound 0
+ ; The directory that just failed the purge, not whatever a check before the directory
+ ; page looked at: a portable install into a custom folder reaches here with the two
+ ; being different.
+ StrCpy $GuardDir $INSTDIR
+ Call RunHolderProbe
+ StrCpy $HolderExit $GuardExit
+ StrCpy $HolderOutput $GuardOutput
+ ; The exit code, not the text, decides what happened: it cannot be mangled by what
+ ; nsExec leaves on the end of the captured output. 1 = the line below names what is
+ ; holding the files, 0 = nothing holds them any more, anything else = the probe could
+ ; not answer, and says why in its own output.
+ ${If} $HolderExit == "1"
+ StrCpy $HolderFound 1
+ ${ElseIf} $HolderExit == "0"
+ StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory"
+ ${Else}
+ StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput"
+ ${EndIf}
+ !insertmacro CockLog "holders: $HolderOutput"
FunctionEnd
-Function un.ForceCloseApps
- ; stop the matching instances by PID once the grace period is over, see
- ; ForceCloseApps in the installer.
- System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
- nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"'
- Pop $R3
-FunctionEnd
-
-Function un.WaitForAppToClose
- Call un.IsAppRunning
- ; Only a definite "running" is worth waiting for, see WaitForAppToClose.
- ${If} $R0 != 1
+; Called after a purge left files behind and LogStaleRuntimeDiagnostics named who is
+; holding them. Instead of aborting straight away - which on /R strands the user, since
+; the old version is already uninstalled - it closes once, purges again, and only then
+; asks: end what is still holding the files, or try the removal again by hand. Sets
+; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts when the
+; files survive.
+;
+; The shape is the guard's, deliberately: one automatic attempt at a graceful close, then a
+; prompt whose Yes ends the payload programs and whose No retries. A client that was still
+; shutting down releases the files in the automatic round and never sees a question, and an
+; Oracle or Servatrice - which a close cannot reach - is what the prompt exists for, so the
+; user is never left with a dialog whose answers cannot free anything.
+;
+; The automatic close is a request (WM_CLOSE) and the client keeps the last word: it may be
+; waiting for an answer to its own "really quit?" confirmation (a running card database
+; update, an open game, an unsaved deck), and that stays its decision. The instance is
+; brought forward first, so that confirmation is on screen rather than behind this
+; installer's prompt.
+;
+; Ending is the same last resort the guard offers, with the same cost spelled out, and only
+; ever the programs the payload installs - see EndHolders.
+;
+; Restart Manager's RmShutdown was the alternative and was rejected: it shuts down
+; services as well as applications, and the only way to bring them back is RmRestart,
+; which would restart this very application into files that are about to be replaced.
+;
+; Always offered, in every mode: a /S install without /R is the only case without someone
+; at the keyboard, and this installer does not promise unattended runs - if an error needs
+; a decision, it asks for one. No offer when the probe named nobody: there is nothing to
+; end, and the abort's per-file diagnostics already record what Windows said.
+;
+; One question after the automatic close, and never a loop: Yes ends the programs the
+; payload installs - and says so, because anything else the probe named is left to the user -
+; and purges again; the other answer gives up, and the caller's abort names what survived.
+; The listing is re-read before the question, so it says what is left now rather than
+; repeating the first one.
+Function RecoverFromHolders
+ ${If} $HolderFound != 1
+ !insertmacro CockLog "recover-holders: no holder was named, nothing to offer"
Return
${EndIf}
-
- ${If} ${Silent}
- DetailPrint "Closing $R1 ..."
- Call un.CloseMatchingApps
- ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so
- ; a bare loop counter would not bound the real wait time to 60 s.
- System::Call 'kernel32::GetTickCount()i.s'
- Pop $R8
- un_ck_wait_loop:
- Sleep 500
- Call un.IsAppRunning
- ${If} $R0 = 0
- DetailPrint "$R1 closed."
- Return
- ${EndIf}
- ${If} $R0 != 1
- Return
- ${EndIf}
- System::Call 'kernel32::GetTickCount()i.s'
- Pop $R9
- IntOp $R9 $R9 - $R8
- ${If} $R9 < 60000
- Goto un_ck_wait_loop
- ${EndIf}
- ; The grace period is over. Cockatrice is never force-stopped, see
- ; WaitForAppToClose in the installer. A silent uninstall only reports
- ; through the log and its exit code: it is also run unattended (the /R
- ; updater uses it via QuietUninstallString, package managers use it
- ; directly), and a message box would block there. The installer that ran
- ; us reports the incomplete result to the user itself.
- ${If} $AllowForceClose = 0
- DetailPrint "$R1 is still running after 60 s; aborting so that no data is lost."
- SetErrorLevel 1
- Abort
- ${EndIf}
- DetailPrint "Force-stopping $R1 ..."
- Call un.ForceCloseApps
- Sleep 2000
- Call un.IsAppRunning
- ${If} $R0 = 0
- DetailPrint "$R1 closed."
- Return
- ${EndIf}
- ${If} $R0 != 1
- Return
- ${EndIf}
- DetailPrint "$R1 is still running after 60 s; aborting."
- SetErrorLevel 1
- Abort
- ${Else}
- un_ck_wait_prompt:
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
- "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \
- IDCANCEL un_ck_abort_install
- Call un.IsAppRunning
- ${If} $R0 = 0
- Return
- ${EndIf}
- ${If} $R0 != 1
- Return
- ${EndIf}
- Goto un_ck_wait_prompt
- un_ck_abort_install:
- Abort
+ ; The automatic attempt, the same one the guard makes before it asks anything.
+ !insertmacro CockLog "recover-holders: closing cockatrice, then trying the removal again"
+ StrCpy $GuardDir $INSTDIR
+ Call CloseHolders
+ Call RetryDllPurge
+ StrCpy $StaleRuntimeAfterPurge $R0
+ !insertmacro CockLogListing "after-retry-purge" "*.dll"
+ !insertmacro CockLogListing "after-retry-plugins" "Plugins\*.*"
+ !insertmacro CockLog "recover-holders: stale-runtime after retry=$StaleRuntimeAfterPurge"
+ ${If} $StaleRuntimeAfterPurge = 0
+ DetailPrint "Files of the previous version were removed; continuing the installation."
+ Return
${EndIf}
+ ; Still stuck: refresh the names, so the prompt and the log describe what survived the
+ ; last attempt and not what was there before it.
+ Call LogStaleRuntimeDiagnostics
+ ${If} $HolderFound != 1
+ Return
+ ${EndIf}
+ ; One question, the same one the guard asks and with the same two answers. No retry loop:
+ ; the purge has already been tried after the close, so the only thing that can still free
+ ; the files is ending them, and the caller's abort names whatever survives that.
+ !insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation"
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end
+ !insertmacro CockLog "recover-holders: cancelled by user"
+ Return
+ ; Only reached through Yes: Return does not come back here.
+ rfh_end:
+ !insertmacro CockLog "recover-holders: ending the payload programs on request, then trying the removal again"
+ Call EndHolders
+ Call RetryDllPurge
+ StrCpy $StaleRuntimeAfterPurge $R0
+ !insertmacro CockLog "recover-holders: stale-runtime after end=$StaleRuntimeAfterPurge"
+ ${If} $StaleRuntimeAfterPurge = 0
+ DetailPrint "Files of the previous version were removed; continuing the installation."
+ Return
+ ${EndIf}
+ ; Refresh the names one last time so the abort that follows describes what survived the
+ ; end, and then let it stop the run: the same dialog again would only repeat itself.
+ Call LogStaleRuntimeDiagnostics
FunctionEnd
+; Uninstaller copy of the guard (the uninstaller gets its own function set compiled in, it
+; cannot call the installer functions). ExtractHolderProbe and RunHolderProbe come from
+; CockHolderProbeFunctions above; only the policy differs.
Function un.EnsureAppsNotRunning
; guards the install directory; the uninstaller only ever has to check this one
StrCpy $GuardDir $INSTDIR
@@ -1314,29 +2058,80 @@ Function un.EnsureAppsNotRunning
FunctionEnd
Function un.GuardAppsNotRunning
- Call un.IsProcessLookupAvailable
- ${If} $R0 = 0
- ; Same reasoning as in the installer: without PowerShell we cannot tell
- ; whether a running instance still holds file locks, and refusing to
- ; uninstall would leave the user with an uninstaller that never
- ; completes. Warn once and continue.
- DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the uninstall reports a problem."
- MessageBox MB_OK|MB_ICONEXCLAMATION "This uninstaller 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."
+ StrCpy $GuardCloseRounds 0
+ StrCpy $GuardRecheckCount 0
+ StrCpy $GuardLastHolders ""
+ un_guard_check:
+ !insertmacro CockLogUn "uninst-guard: checking $GuardDir"
+ Call un.RunHolderProbe
+ ${If} $GuardExit == "0"
+ !insertmacro CockLogUn "uninst-guard: nothing is holding the payload"
Return
${EndIf}
- StrCpy $R1 "cockatrice.exe"
- StrCpy $R2 "cockatrice"
- ; a GUI application with unsaved user data: graceful close only
- StrCpy $AllowForceClose 0
- Call un.WaitForAppToClose
- StrCpy $R1 "oracle.exe"
- StrCpy $R2 "oracle"
- StrCpy $AllowForceClose 1
- Call un.WaitForAppToClose
- StrCpy $R1 "servatrice.exe"
- StrCpy $R2 "servatrice"
- StrCpy $AllowForceClose 1
- Call un.WaitForAppToClose
+ ${If} $GuardExit == "1"
+ !insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput"
+ ; Same convenience as in the installer: ask every instance to close, repeat only
+ ; while the set changed, then report what the close could not reach.
+ ${If} $GuardCloseRounds < 2
+ ${AndIf} $GuardOutput != $GuardLastHolders
+ StrCpy $GuardLastHolders $GuardOutput
+ IntOp $GuardCloseRounds $GuardCloseRounds + 1
+ !insertmacro CockLogUn "uninst-guard: asking the payload programs to close (round $GuardCloseRounds)"
+ Call un.CloseHolders
+ Goto un_guard_check
+ ${EndIf}
+ DetailPrint "The install directory is in use by: $GuardOutput"
+ ; Same question and the same three answers as the installer's guard: force close,
+ ; close by hand and check again, or abort. CockEndRecheckPrompt words it, so the two
+ ; copies promise the same thing.
+ !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation"
+ MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end IDNO un_guard_recheck
+ !insertmacro CockLogUn "uninst-guard: cancelled by user after $GuardRecheckCount recheck(s)"
+ ; The installer's Cancel lands in AbortInstall, which explains what to do next. The
+ ; uninstaller has no such dialog, so say the same thing here: a bare Abort would
+ ; close the window and leave the user with no reason and no next step.
+ MessageBox MB_OK|MB_ICONEXCLAMATION \
+ "The uninstallation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the installation.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the uninstaller again."
+ SetErrorLevel 1
+ Abort
+ un_guard_recheck:
+ IntOp $GuardRecheckCount $GuardRecheckCount + 1
+ !insertmacro CockLogUn "uninst-guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)"
+ StrCpy $GuardCloseRounds 0
+ StrCpy $GuardLastHolders ""
+ Goto un_guard_check
+ ; Only reached through Yes: Abort does not return.
+ un_guard_end:
+ !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again"
+ Call un.EndHolders
+ ; One measurement and a stop, not another question: what survived a terminate is a
+ ; program this uninstaller may not open or does not own, and ending it again cannot
+ ; change that.
+ Call un.RunHolderProbe
+ ${If} $GuardExit == "0"
+ !insertmacro CockLogUn "uninst-guard: nothing is holding the payload"
+ Return
+ ${EndIf}
+ !insertmacro CockLogUn "uninst-guard: still in use after ending them ($GuardExit): $GuardOutput"
+ SetErrorLevel 1
+ Abort
+ ${EndIf}
+ ; Same reasoning as in the installer: without an answer from the probe we cannot tell
+ ; whether a running instance still holds file locks, and refusing outright would leave
+ ; the user with an uninstaller that never completes. Same three answers too, so an
+ ; uninstall that hits a Restart Manager that was busy, or a payload program the user has
+ ; not closed yet, can simply be retried.
+ !insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput"
+ DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput"
+ MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
+ "This uninstaller could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \
+ IDRETRY un_guard_check IDABORT un_guard_unknown_abort
+ !insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe"
+ Return
+ un_guard_unknown_abort:
+ !insertmacro CockLogUn "uninst-guard: cancelled after the probe could not answer"
+ SetErrorLevel 1
+ Abort
FunctionEnd
Function PortableModePageCreate
@@ -1392,7 +2187,10 @@ ${If} $PortableMode = 0
${If} $ReinstallMode = 0
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst32
- Abort
+ ; Abort would only skip this page and install over the old version anyway;
+ ; the message said the old one must go first, so Cancel means cancel.
+ !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt"
+ Quit
${Else}
Goto uninst32
${EndIf}
@@ -1409,10 +2207,24 @@ ${If} $PortableMode = 0
StrCpy $OldUninstallString $R0
Call EnsureOldInstallNotRunning
StrCpy $OldInstallDir $GuardDir
+ ; Run it silently. The question above is the one the user answered; the
+ ; uninstaller's own confirmation page would be a second one, and that one opens
+ ; behind this window, where nobody can answer it. The installer then looks hung,
+ ; the user clicks Next on it, the new version is installed, and the uninstaller
+ ; removes everything once this window closes. /S leaves no window to be behind,
+ ; so ExecWait is waiting for something that cannot outlive it.
+ ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
+ ${If} $R1 == ""
+ StrCpy $OldUninstallString "$OldUninstallString /S"
+ ${Else}
+ StrCpy $OldUninstallString $R1
+ ${EndIf}
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
ClearErrors
Call BackupUserDataBeforeUninstall
- ExecWait "$OldUninstallString _?=$OldInstallDir"
+ Call RunOldUninstaller
+ !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$OldUninstallExit"
+ Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done32:
@@ -1426,7 +2238,8 @@ ${If} $PortableMode = 0
${If} $ReinstallMode = 0
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst64
- Abort
+ !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt"
+ Quit
${Else}
Goto uninst64
${EndIf}
@@ -1435,10 +2248,19 @@ ${If} $PortableMode = 0
StrCpy $OldUninstallString $R0
Call EnsureOldInstallNotRunning
StrCpy $OldInstallDir $GuardDir
+ ; Silent, for the reason given in the 32-bit branch above.
+ ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
+ ${If} $R1 == ""
+ StrCpy $OldUninstallString "$OldUninstallString /S"
+ ${Else}
+ StrCpy $OldUninstallString $R1
+ ${EndIf}
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
ClearErrors
Call BackupUserDataBeforeUninstall
- ExecWait "$OldUninstallString _?=$OldInstallDir"
+ Call RunOldUninstaller
+ !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$OldUninstallExit"
+ Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done64:
@@ -1478,6 +2300,23 @@ ${If} $ReinstallMode = 0
Call EnsureAppsNotRunning
${EndIf}
+; Measured before either purge block, because it has to describe the directory as
+; it arrived here and not what a purge left of it: IsExistingCockatriceInstall
+; looks for cockatrice.exe, oracle.exe, servatrice.exe, uninstall.exe, Qt6*.dll
+; and qt.conf, and the portable purge below deletes exactly those. Taken after
+; that purge it answered "not an install" as soon as the only survivor was a file
+; it does not look for - a locked abseil_dll.dll or libssl-3-x64.dll, say - and
+; the abort further down was then skipped for the very directory the purge had
+; just failed in.
+; 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.
+Call IsExistingCockatriceInstall
+!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0"
+StrCpy $IsUpgrade $R0
+
${If} $PortableMode = 1
${AndIf} ${FileExists} "$INSTDIR\portable.dat"
; upgrade portable mode
@@ -1501,7 +2340,12 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
; what this has to guarantee is that nothing from the previous build is left
; to be loaded next to the new executables. Naming the families by hand is
; what let abseil_dll.dll through; see HasStaleRuntimeFiles.
- Delete "$INSTDIR\*.dll"
+ Call RetryDllPurge
+ ; The purge's own verdict: HasStaleRuntimeFiles measures *.dll + Plugins\*.*,
+ ; the set that catches any leftover runtime DLL, Qt or otherwise. Held in a
+ ; variable because $R0 is reused by the next function call in this section.
+ StrCpy $StaleRuntimeAfterPurge $R0
+ StrCpy $DllPurgeRan 1
Delete "$INSTDIR\qt.conf"
Delete "$INSTDIR\qdebug.txt"
Delete "$INSTDIR\servatrice.sql"
@@ -1512,20 +2356,33 @@ ${EndIf}
; Remove any runtime DLLs left over from older versions, so a mismatched
; Qt/OpenSSL/protobuf set cannot survive next to the new executables.
-Call IsExistingCockatriceInstall
-!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0"
-; 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
+;
+; A purge that fails is reported with evidence, not only as a survivor list: the
+; abort below logs which files survived, what Windows said about each of them and
+; which processes still have the install directory mapped (see
+; LogStaleRuntimeDiagnostics).
+;
+; Re-running the running-instance check here was considered and dropped: it would only
+; narrow the window the uninstaller ExecWait and the backup/restore copy open between
+; .onInit and this point, and on /R it would now abort after the old version has already
+; been removed - leaving no install at all. The surviving files are the authority on
+; whether anything still holds the directory, and they are measured anyway.
+;
+; The holder list comes from Restart Manager (RmRegisterResources/RmGetList), the API
+; that names every process and service with one of these files open or mapped. It is
+; called from tools/installer-holder-probe rather than from this script, because the
+; RM_PROCESS_INFO array is a table of hand-computed offsets when it is marshalled
+; through the System plugin, and that table is the one thing here that cannot be
+; checked without a Windows toolchain. Compiled by the same build that compiles this
+; installer, the layout belongs to the Windows SDK, and .ci/compile.sh runs the probe
+; on the Windows runners instead of trusting that it compiles.
${If} $PortableMode = 0
-${AndIf} $R0 = 1
+${AndIf} $IsUpgrade = 1
!insertmacro CockLog "section: non-portable upgrade purge"
!insertmacro CockLogListing "before-purge" "*.dll"
- RMDir /r "$INSTDIR\Plugins"
- Delete "$INSTDIR\*.dll"
+ Call RetryDllPurge
+ StrCpy $StaleRuntimeAfterPurge $R0
+ StrCpy $DllPurgeRan 1
!insertmacro CockLogListing "after-purge" "*.dll"
!insertmacro CockLogListing "after-plugins" "Plugins\*.*"
${EndIf}
@@ -1540,22 +2397,78 @@ ${EndIf}
; run on Windows or it contains an error", 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.
+; $StaleRuntimeAfterPurge is RetryDllPurge's verdict, measured with
+; HasStaleRuntimeFiles (*.dll + Plugins\*.*) - the set that catches any leftover
+; runtime DLL, Qt or otherwise. It exists only when a purge ran, and the two
+; purge blocks above cover every upgrade except one: a portable install into a
+; directory without portable.dat is neither of them, so that directory is
+; measured here instead. A test of $IsUpgrade alone could not stand in for it -
+; that only says the directory was ours before the purge, not what the purge
+; left behind.
+; A directory this installer did not recognise as an install never aborts: the
+; unrelated DLLs already in it (a first-time install into a folder that happens
+; to hold some Qt5 DLLs, say) are not leftovers, and the payload overwrites
+; nothing of theirs.
${If} $IsUpgrade = 1
- Call HasStaleRuntimeFiles
- !insertmacro CockLog "section: HasStaleRuntimeFiles=$R0"
+ ${If} $DllPurgeRan = 0
+ !insertmacro CockLog "section: no purge applies, measuring the directory instead"
+ Call HasStaleRuntimeFiles
+ StrCpy $StaleRuntimeAfterPurge $R0
+ ${EndIf}
+ !insertmacro CockLog "section: stale-runtime after purge=$StaleRuntimeAfterPurge"
${Else}
- StrCpy $R0 0
+ StrCpy $StaleRuntimeAfterPurge 0
${EndIf}
-${If} $R0 = 1
+${If} $StaleRuntimeAfterPurge = 1
+ !insertmacro CockLog "section: files of a previous version survived the purge"
+ ; Who is holding the payload is recorded first, so the log that reaches us
+ ; answers the question the message can only guess at, and so the prompt can
+ ; name them and offer to close the ones this installer owns.
+ Call LogStaleRuntimeDiagnostics
+ ${If} $HolderFound = 1
+ ; On /R the previous version is already uninstalled at this point, so an abort
+ ; leaves the user with no working Cockatrice. It closes once, then offers to end
+ ; what is left; this updates $StaleRuntimeAfterPurge with the verdict after that
+ ; close, or after the end when the user asked for one.
+ Call RecoverFromHolders
+ ${Else}
+ ; Nothing is holding them. The three attempts in RetryDllPurge were two seconds
+ ; apart and all failed with no process to blame, which is the transient case a
+ ; virus scanner or a background indexer creates - or a permission problem that
+ ; will not clear. Give the delete one last chance before giving up: it costs a
+ ; second on a path that is about to abort anyway, and it keeps a lock that was
+ ; released in the meantime from turning into a failed upgrade.
+ !insertmacro CockLog "section: no holder named, one final purge attempt"
+ Sleep 1000
+ Call RetryDllPurge
+ StrCpy $StaleRuntimeAfterPurge $R0
+ !insertmacro CockLog "section: final-purge stale-runtime=$StaleRuntimeAfterPurge"
+ ${EndIf}
+${EndIf}
+${If} $StaleRuntimeAfterPurge = 1
!insertmacro CockLog "section: aborting, files of a previous version survived the purge"
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. AbortInstall owns that decision.
+ ; Say what is known and nothing more: the guard ran before anything was replaced,
+ ; so "most likely still running" is no longer a fair guess and would send the user
+ ; to close programs that are already closed.
StrCpy $AbortReason \
- "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."
+ "The installation could not remove all files of a previous version of Cockatrice, and was cancelled rather than leave a mismatched installation behind."
+ ${If} $HolderFound = 1
+ StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput$\r$\n$\r$\nClose them and run the installer again."
+ ${ElseIf} $HolderExit == "0"
+ StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nNo running program was found using those files. A file permission or a virus scanner may be preventing their removal; the installer log records what Windows reported for each file."
+ ${Else}
+ ; The check itself failed, so "no running program was found" would be a claim this
+ ; installer cannot make. Name the failure instead, and leave the per-file codes to
+ ; the log, where they already are.
+ StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nThe check for a running program could not be completed: $HolderOutput$\r$\n$\r$\nClose Cockatrice, Oracle and Servatrice and run the installer again; the installer log records what Windows reported for each file."
+ ${EndIf}
+ ; "Run the installer again" is not enough on an in-app update: the old version
+ ; was uninstalled before the purge, so point at the installer that was downloaded.
+ ${If} $ReinstallMode = 1
+ ${AndIf} $PortableMode = 0
+ StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nThe previous version has already been removed. To finish the update, run this installer again:$\r$\n$EXEPATH"
+ ${EndIf}
Call AbortInstall
${EndIf}
diff --git a/cmake/SendInstallerLog.ps1.in b/cmake/SendInstallerLog.ps1.in
index dfb8c3d63..585c726dd 100644
--- a/cmake/SendInstallerLog.ps1.in
+++ b/cmake/SendInstallerLog.ps1.in
@@ -57,6 +57,29 @@ try {
}
}
+ # --- What was holding the files, read from the log as well -------------------
+ # The guard and the close attempt each log one line naming every holder and what
+ # happened to it. Carrying the last of each as event data keeps an abort caused by
+ # a running instance actionable without opening the attachment. Kept out of the
+ # message so the issue still groups on the reason alone.
+ $Holders = ''
+ $CloseResult = ''
+ $EndResult = ''
+ if (Test-Path -LiteralPath $LogPath) {
+ $holderLine = Select-String -LiteralPath $LogPath -Pattern 'guard: still in use by (.*)$' | Select-Object -Last 1
+ if ($holderLine) {
+ $Holders = $holderLine.Matches[0].Groups[1].Value
+ }
+ $closeLine = Select-String -LiteralPath $LogPath -Pattern '(close-holders: .*)$' | Select-Object -Last 1
+ if ($closeLine) {
+ $CloseResult = $closeLine.Matches[0].Groups[1].Value
+ }
+ $endLine = Select-String -LiteralPath $LogPath -Pattern '(end-holders: .*)$' | Select-Object -Last 1
+ if ($endLine) {
+ $EndResult = $endLine.Matches[0].Groups[1].Value
+ }
+ }
+
# --- Event ---------------------------------------------------------------
$eventId = [guid]::NewGuid().ToString('N')
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
@@ -75,6 +98,9 @@ try {
extra = @{
reason = $Reason
os = [System.Environment]::OSVersion.VersionString
+ holders = $Holders
+ close_result = $CloseResult
+ end_result = $EndResult
}
}
$eventJson = $sentryEvent | ConvertTo-Json -Compress -Depth 6
diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index bec858dcb..ade7c3976 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -25,9 +25,7 @@ set(cockatrice_SOURCES
src/client/settings/shortcut_treeview.cpp
src/client/settings/shortcuts_settings.cpp
src/interface/deck_loader/card_node_function.cpp
- src/interface/deck_loader/deck_file_format.cpp
src/interface/deck_loader/deck_loader.cpp
- src/interface/deck_loader/loaded_deck.cpp
src/interface/widgets/dialogs/dlg_connect.cpp
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
src/interface/widgets/dialogs/dlg_create_game.cpp
@@ -71,36 +69,27 @@ set(cockatrice_SOURCES
src/filters/filter_builder.cpp
src/filters/filter_tree_model.cpp
src/filters/syntax_help.cpp
- src/game/abstract_game.cpp
- src/game/arrow_registry.cpp
src/game_graphics/board/abstract_card_drag_item.cpp
src/game_graphics/board/abstract_card_item.cpp
src/game_graphics/board/abstract_counter.cpp
- src/game/board/arrow_data.cpp
src/game_graphics/board/arrow_item.cpp
+ src/game_graphics/board/arrow_registry.cpp
src/game_graphics/board/arrow_target.cpp
src/game_graphics/board/card_drag_item.cpp
src/game_graphics/board/card_item.cpp
- src/game/board/card_list.cpp
- src/game/board/card_state.cpp
src/game_graphics/board/counter_general.cpp
- src/game/board/counter_state.cpp
src/game_graphics/board/translate_counter_name.cpp
src/game_graphics/deckview/deck_view.cpp
src/game_graphics/deckview/deck_view_container.cpp
src/game_graphics/deckview/tabbed_deck_view_container.cpp
+ src/game_graphics/dialogs/dlg_annotation.cpp
src/game_graphics/dialogs/dlg_create_token.cpp
src/game_graphics/dialogs/dlg_move_top_cards_until.cpp
src/game_graphics/dialogs/dlg_roll_dice.cpp
- src/game/game.cpp
- src/game/game_event_handler.cpp
- src/game/game_meta_info.cpp
src/game_graphics/game_scene.cpp
- src/game/game_state.cpp
src/game_graphics/game_view.cpp
src/game_graphics/hand_counter.cpp
src/game_graphics/log/message_log_widget.cpp
- src/game/phase.cpp
src/game_graphics/phases_toolbar.cpp
src/game_graphics/player/menu/card_menu.cpp
src/game_graphics/player/menu/custom_zone_menu.cpp
@@ -118,23 +107,11 @@ set(cockatrice_SOURCES
src/game_graphics/tally/stats_tally.cpp
src/game_graphics/tally/subtype_tally.cpp
src/game_graphics/tally/tally.cpp
- src/game/player/player_actions.cpp
src/game_graphics/player/player_area.cpp
src/game_graphics/player/player_dialogs.cpp
- src/game/player/player_event_handler.cpp
src/game_graphics/player/player_graphics_item.cpp
- src/game/player/player_info.cpp
src/game_graphics/player/player_list_widget.cpp
- src/game/player/player_logic.cpp
- src/game/player/player_manager.cpp
src/game_graphics/player/player_target.cpp
- src/game/replay.cpp
- src/game/zones/card_zone_logic.cpp
- src/game/zones/hand_zone_logic.cpp
- src/game/zones/pile_zone_logic.cpp
- src/game/zones/stack_zone_logic.cpp
- src/game/zones/table_zone_logic.cpp
- src/game/zones/view_zone_logic.cpp
src/game_graphics/zones/card_zone.cpp
src/game_graphics/zones/hand_zone.cpp
src/game_graphics/zones/pile_zone.cpp
@@ -172,6 +149,7 @@ set(cockatrice_SOURCES
src/interface/widgets/utility/reversed_completer_model.cpp
src/interface/pixel_map_generator.cpp
src/interface/theme_config.cpp
+ src/interface/theme_icon.cpp
src/interface/theme_manager.cpp
src/interface/palette_editor/color_button.cpp
src/interface/palette_editor/palette_generator.cpp
@@ -194,6 +172,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/card_info_picture_widget.cpp
src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp
src/interface/widgets/cards/card_info_text_widget.cpp
+ src/interface/widgets/cards/card_picture_delegate.cpp
src/interface/widgets/cards/card_size_widget.cpp
src/interface/widgets/cards/deck_card_zone_display_widget.cpp
src/interface/widgets/cards/deck_preview_card_picture_widget.cpp
@@ -313,6 +292,7 @@ set(cockatrice_SOURCES
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
+ src/interface/widgets/visual_database_display/card_database_visual_view.cpp
src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp
@@ -345,6 +325,7 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
src/interface/window_main.cpp
+ src/installer_close_server.cpp
src/main.cpp
src/single_instance_manager.cpp
src/interface/widgets/tabs/abstract_tab_deck_editor.cpp
@@ -450,6 +431,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h
src/interface/widgets/utility/compact_push_button.cpp
src/interface/widgets/utility/compact_push_button.h
+ src/installer_close_server.h
src/single_instance_manager.h
src/client/url_scheme_event_filter.h
src/interface/intents/intent_connect_to_server.cpp
@@ -511,7 +493,7 @@ if(APPLE)
set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns)
endif(APPLE)
-qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
+qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
# Declare path variables
set(ICONDIR
@@ -532,7 +514,7 @@ set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translation
set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations")
set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations")
-qt6_add_executable(
+qt_add_executable(
cockatrice
WIN32
MACOSX_BUNDLE
@@ -544,7 +526,7 @@ qt6_add_executable(
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
-qt6_add_shaders(
+qt_add_shaders(
cockatrice
"onboarding_shaders"
PREFIX
@@ -556,7 +538,7 @@ qt6_add_shaders(
src/interface/widgets/onboarding/shaders/brand_plate.frag
)
-qt6_add_resources(
+qt_add_resources(
cockatrice
"onboarding_qml"
PREFIX
@@ -572,6 +554,7 @@ target_link_libraries(
PUBLIC libcockatrice_card
libcockatrice_deck_list
libcockatrice_filters
+ libcockatrice_game
libcockatrice_utility
libcockatrice_network
libcockatrice_models
@@ -788,7 +771,7 @@ endif()
if(Qt6LinguistTools_FOUND)
#Qt6 Translations happen after the executable is built up
if(UPDATE_TRANSLATIONS)
- qt6_add_translations(
+ qt_add_translations(
cockatrice
TS_FILES
${cockatrice_TS}
@@ -798,7 +781,7 @@ if(Qt6LinguistTools_FOUND)
cockatrice_QM
)
else()
- qt6_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM)
+ qt_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM)
endif()
if(UNIX)
@@ -824,4 +807,4 @@ if(Qt6LinguistTools_FOUND)
endforeach()
endif()
-qt6_finalize_target(cockatrice)
+qt_finalize_target(cockatrice)
diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc
index 4ec3d2385..229cdd125 100644
--- a/cockatrice/cockatrice.qrc
+++ b/cockatrice/cockatrice.qrc
@@ -6,19 +6,18 @@
resources/hand.svgresources/hr.jpg
- resources/icons/arrow_bottom_green.svg
- resources/icons/arrow_down_green.svg
+ resources/icons/arrow_down.svgresources/icons/arrow_history.svg
- resources/icons/arrow_left_green.svg
+ resources/icons/arrow_left.svgresources/icons/arrow_redo.svg
- resources/icons/arrow_right_blue.svg
- resources/icons/arrow_right_green.svg
- resources/icons/arrow_top_green.svg
- resources/icons/arrow_up_green.svg
+ resources/icons/arrow_right.svg
+ resources/icons/arrow_right_sideboard.svg
+ resources/icons/arrow_to_bottom.svg
+ resources/icons/arrow_to_top.svg
+ resources/icons/arrow_up.svgresources/icons/arrow_undo.svgresources/icons/circle_half_stroke.svgresources/icons/clearsearch.svg
- resources/icons/cogwheel.svgresources/icons/conceded.svgresources/icons/decrement.svgresources/icons/delete.svg
@@ -28,7 +27,6 @@
resources/icons/filter.svgresources/icons/floppy_disk.svgresources/icons/forgot_password.svg
- resources/icons/gear.svgresources/icons/increment.svgresources/icons/info.svgresources/icons/lock.svg
@@ -41,7 +39,6 @@
resources/icons/reload.svgresources/icons/remove_row.svgresources/icons/rename.svg
- resources/icons/scale_balanced.svgresources/icons/scales.svgresources/icons/scroll.svgresources/icons/search.svg
@@ -52,7 +49,6 @@
resources/icons/swap.svgresources/icons/sync.svgresources/icons/tab_changed.svg
- resources/icons/update.pngresources/icons/view.svgresources/icons/mana/B.svg
diff --git a/cockatrice/resources/icons/arrow_bottom_green.svg b/cockatrice/resources/icons/arrow_bottom_green.svg
deleted file mode 100644
index 3a45053ad..000000000
--- a/cockatrice/resources/icons/arrow_bottom_green.svg
+++ /dev/null
@@ -1,793 +0,0 @@
-
-
-
-
diff --git a/cockatrice/resources/icons/arrow_down.svg b/cockatrice/resources/icons/arrow_down.svg
new file mode 100644
index 000000000..0ff237f52
--- /dev/null
+++ b/cockatrice/resources/icons/arrow_down.svg
@@ -0,0 +1,4 @@
+
diff --git a/cockatrice/resources/icons/arrow_down_green.svg b/cockatrice/resources/icons/arrow_down_green.svg
deleted file mode 100644
index 574c12060..000000000
--- a/cockatrice/resources/icons/arrow_down_green.svg
+++ /dev/null
@@ -1,793 +0,0 @@
-
-
-
-
diff --git a/cockatrice/resources/icons/arrow_history.svg b/cockatrice/resources/icons/arrow_history.svg
index d130758e6..29374d23a 100644
--- a/cockatrice/resources/icons/arrow_history.svg
+++ b/cockatrice/resources/icons/arrow_history.svg
@@ -1,8 +1,5 @@
-
-
\ No newline at end of file
+
diff --git a/cockatrice/resources/icons/arrow_left.svg b/cockatrice/resources/icons/arrow_left.svg
new file mode 100644
index 000000000..7a88deddb
--- /dev/null
+++ b/cockatrice/resources/icons/arrow_left.svg
@@ -0,0 +1,4 @@
+
diff --git a/cockatrice/resources/icons/arrow_left_green.svg b/cockatrice/resources/icons/arrow_left_green.svg
deleted file mode 100644
index 1b9eb0684..000000000
--- a/cockatrice/resources/icons/arrow_left_green.svg
+++ /dev/null
@@ -1,791 +0,0 @@
-
-
-
-
diff --git a/cockatrice/resources/icons/arrow_redo.svg b/cockatrice/resources/icons/arrow_redo.svg
index dd89e3314..141260269 100644
--- a/cockatrice/resources/icons/arrow_redo.svg
+++ b/cockatrice/resources/icons/arrow_redo.svg
@@ -1,40 +1,4 @@
-
-
-
-
\ No newline at end of file
+
diff --git a/cockatrice/resources/icons/arrow_right.svg b/cockatrice/resources/icons/arrow_right.svg
new file mode 100644
index 000000000..f0ac5d5bf
--- /dev/null
+++ b/cockatrice/resources/icons/arrow_right.svg
@@ -0,0 +1,4 @@
+
diff --git a/cockatrice/resources/icons/arrow_right_blue.svg b/cockatrice/resources/icons/arrow_right_blue.svg
deleted file mode 100644
index 334cd68fd..000000000
--- a/cockatrice/resources/icons/arrow_right_blue.svg
+++ /dev/null
@@ -1,789 +0,0 @@
-
-
-
diff --git a/cockatrice/resources/icons/arrow_right_green.svg b/cockatrice/resources/icons/arrow_right_green.svg
deleted file mode 100644
index db491985d..000000000
--- a/cockatrice/resources/icons/arrow_right_green.svg
+++ /dev/null
@@ -1,789 +0,0 @@
-
-
-
diff --git a/cockatrice/resources/icons/arrow_right_sideboard.svg b/cockatrice/resources/icons/arrow_right_sideboard.svg
new file mode 100644
index 000000000..79a8cd5d3
--- /dev/null
+++ b/cockatrice/resources/icons/arrow_right_sideboard.svg
@@ -0,0 +1,5 @@
+
diff --git a/cockatrice/resources/icons/arrow_to_bottom.svg b/cockatrice/resources/icons/arrow_to_bottom.svg
new file mode 100644
index 000000000..291a527d2
--- /dev/null
+++ b/cockatrice/resources/icons/arrow_to_bottom.svg
@@ -0,0 +1,5 @@
+
diff --git a/cockatrice/resources/icons/arrow_to_top.svg b/cockatrice/resources/icons/arrow_to_top.svg
new file mode 100644
index 000000000..aac38cc33
--- /dev/null
+++ b/cockatrice/resources/icons/arrow_to_top.svg
@@ -0,0 +1,5 @@
+
diff --git a/cockatrice/resources/icons/arrow_top_green.svg b/cockatrice/resources/icons/arrow_top_green.svg
deleted file mode 100644
index c515c6a1d..000000000
--- a/cockatrice/resources/icons/arrow_top_green.svg
+++ /dev/null
@@ -1,793 +0,0 @@
-
-
-
-
diff --git a/cockatrice/resources/icons/arrow_undo.svg b/cockatrice/resources/icons/arrow_undo.svg
index 2e182ffb2..c748b37b8 100644
--- a/cockatrice/resources/icons/arrow_undo.svg
+++ b/cockatrice/resources/icons/arrow_undo.svg
@@ -1,40 +1,4 @@
-
-
-
-
\ No newline at end of file
+
diff --git a/cockatrice/resources/icons/arrow_up.svg b/cockatrice/resources/icons/arrow_up.svg
new file mode 100644
index 000000000..ac32b5c92
--- /dev/null
+++ b/cockatrice/resources/icons/arrow_up.svg
@@ -0,0 +1,4 @@
+
diff --git a/cockatrice/resources/icons/arrow_up_green.svg b/cockatrice/resources/icons/arrow_up_green.svg
deleted file mode 100644
index 1d0f61112..000000000
--- a/cockatrice/resources/icons/arrow_up_green.svg
+++ /dev/null
@@ -1,792 +0,0 @@
-
-
-
-
diff --git a/cockatrice/resources/icons/circle_half_stroke.svg b/cockatrice/resources/icons/circle_half_stroke.svg
index 42e4dbca2..c16f84858 100644
--- a/cockatrice/resources/icons/circle_half_stroke.svg
+++ b/cockatrice/resources/icons/circle_half_stroke.svg
@@ -1 +1,4 @@
-
\ No newline at end of file
+
diff --git a/cockatrice/resources/icons/clearsearch.svg b/cockatrice/resources/icons/clearsearch.svg
index be50d68d6..49d883c68 100644
--- a/cockatrice/resources/icons/clearsearch.svg
+++ b/cockatrice/resources/icons/clearsearch.svg
@@ -1,276 +1,4 @@
-
-
-
-