Cockatrice/cmake/NSIS.template.in
sentry[bot] bf3562a85a
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
[Installer] Offer to end running Cockatrice immediately when auto-close fails (#7675)
* [Installer] Offer to end running Cockatrice immediately when auto-close fails

* [Installer] Keep the way out that costs nothing in the first prompt

The prompt now offers to end Cockatrice, so what the deleted close-again text
said has to survive in it: an instance the probe reports as waiting for an
answer to a question of its own has been brought forward, and answering that
window is the way out that ends nothing. Say so, and say where to go when Yes
leaves it listed - Task Manager - so an end that cannot take effect does not
loop on the same dialog.

* [Installer] Bring the comments back in line with the code

GuardCloseTried no longer counts closes, and the file header, CloseHolders and
GuardAppsNotRunning still described an escalation after a second close, as did
the probe's --end-holders note.

* [Installer] End Oracle and Servatrice too when the guard's end answer is confirmed

The guard's end answer only ever stopped cockatrice.exe, so an Oracle holding
the card database or a Servatrice service kept the payload locked and the
prompt came back with nothing left that could free it.

The probe now ends every program the payload installs, services included:
isPayloadProgram() - the list restart_manager_holders already used for its
parent walk - decides the set, so a scanner holding a DLL or a program the
user keeps in the install directory is still named and left to the user. A
service is terminated rather than sent a stop through the SCM, which notices
the process exit and marks it stopped afterwards.

The guard prompt now says what ending costs: an unsaved deck, an unwritten
setting, players on a running Servatrice, and an Oracle mid-way through the
card database. It also keeps the notification-area hint, which the probe's
"has no window to ask" answer depends on, and drops the claim that Oracle
and Servatrice had been brought forward.

* [Installer] Give every running-program prompt the same end-or-retry answers

Four prompts meet a running program and each offered a different set: the
guard ended it, the launcher wait only kept waiting, and the recovery prompt
after a failed purge could only ask Cockatrice to close. With Oracle or
Servatrice holding the files, that last one had no answer that could free
anything - Yes and No both retried the purge and Cancel aborted an install
whose old version was already gone.

All four now follow the guard's shape: one automatic attempt, then a prompt
whose Yes ends the programs it names and whose No retries, with Cancel to
give up.

- RecoverFromHolders closes once and purges before it asks anything, then
  offers EndHolders - the same end answer as the guard - instead of a close
  the user had to trigger himself.
- WaitForLauncherToExit can end the launcher by pid, the one process it
  holds a pid for, rather than looping 60 s waits with Cancel as the only
  way out.
- END_COST and AFTER_CLOSING_GUIDANCE are !defines, so the cost of ending
  and the guidance for the answers that end nothing read the same in every
  prompt.

* [Installer] End the launcher through the handle it was waited on

The "end it" answer used to look the launcher's pid up again after a dialog that
can stay open as long as the user takes, so a pid that had been reused in the
meantime - or a /PID= that was never the launcher - would be terminated instead.
The handle the wait already holds names that process and cannot be handed to
another, so the terminate answer now acts on it.

PROCESS_TERMINATE is requested along with PROCESS_SYNCHRONIZE; when only the
synchronize right is granted the launcher is still waited on and the answer falls
back to asking it to close, instead of reporting a live launcher as gone and
writing the payload over it.

* [Installer] Say what the end answer ends, and report a failed end

The prompt offered "end every program listed above", but EndHolders only ends the
programs the payload installs: a scanner holding a payload DLL, or a service the
Restart Manager names by its long name, was listed and then silently left alone, so
Yes looked like it did nothing and the same dialog came back unchanged.

The wording now says which programs Yes ends and that anything else has to be closed
by hand, and the round after an end that did not free the files says so, so the same
dialog no longer reappears word for word. The three prompts are one macro so they
cannot drift apart again.

The added sentences eat into the 1024 characters one NSIS string holds, so the
holder line's budget drops from 400 to 320 characters to keep the longest message
(cancel line included) inside it: 977 characters, measured against the worst case
the probe can produce.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-27 19:09:21 +01:00

2245 lines
111 KiB
Text

!include ..\..\..\NSIS.definitions.nsh
Name "@CPACK_PACKAGE_NAME@"
BrandingText "@CPACK_PACKAGE_FILE_NAME@"
OutFile "@CPACK_TOPLEVEL_DIRECTORY@/@CPACK_OUTPUT_FILE_NAME@"
!define INST_DIR "@CPACK_TEMPORARY_DIRECTORY@"
RequestExecutionlevel admin
SetCompressor LZMA
Var NormalDestDir
Var PortableDestDir
Var PortableMode
Var ReinstallMode
Var GuardDir
Var OldUninstallString
Var OldInstallDir
Var DataBackupDir
Var DataBackupDone
Var DataBackupForDir
Var LooseDecksBackupDone
Var InstallSubdirsBackupDone
Var LooseDeckNoticeShown
Var StaleFindHandle
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
; Whether the guard has already asked Cockatrice to close by itself: 0 before the automatic
; close, 1 after it. The prompt that follows offers to end the instance rather than asking
; a second time (see GuardAppsNotRunning).
Var GuardCloseTried
; Set when the user answered Yes to ending the programs and the files were still in use
; afterwards, so the prompt that follows says the end did not help instead of reappearing
; word for word (see CockEndPrompt).
Var GuardEndFailed
; The question CockEndPrompt assembles: one sentence in it is conditional, 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
Var InstallLogPath
Var LogHandle
Var LogMessage
Var LogFindHandle
Var LogFindName
Var LogMatchLabel
Var LogMatchPattern
; Sentry installer-log hook state (see AbortInstall)
Var SentryResult
Var SentryOutput
!include LogicLib.nsh
!include FileFunc.nsh
!include MUI2.nsh
!include x64.nsh
!define MUI_ABORTWARNING
!define MUI_WELCOMEFINISHPAGE_BITMAP "${NSIS_SOURCE_PATH}\cmake\leftimage.bmp"
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "${NSIS_SOURCE_PATH}\cmake\leftimage.bmp"
!define MUI_HEADERIMAGE
!define MUI_HEADERIMAGE_BITMAP "${NSIS_SOURCE_PATH}\cmake\headerimage.bmp"
!define MUI_HEADERIMAGE_UNBITMAP "${NSIS_SOURCE_PATH}\cmake\headerimage.bmp"
!define MUI_WELCOMEPAGE_TEXT "This wizard will guide you through the installation of Cockatrice.$\r$\n$\r$\nClick Next to continue."
!define MUI_FINISHPAGE_RUN "$INSTDIR/cockatrice.exe"
!define MUI_FINISHPAGE_RUN_TEXT "Run 'Cockatrice' now"
!define MUI_ICON "${NSIS_SOURCE_PATH}\cockatrice\resources\appicon.ico"
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfReinstall
!insertmacro MUI_PAGE_WELCOME
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfReinstall
!insertmacro MUI_PAGE_LICENSE "${NSIS_SOURCE_PATH}\LICENSE"
Page Custom PortableModePageCreate PortableModePageLeave
!define MUI_PAGE_CUSTOMFUNCTION_PRE componentsPagePre
!insertmacro MUI_PAGE_COMPONENTS
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfReinstall
!insertmacro MUI_PAGE_DIRECTORY
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfReinstall
!insertmacro MUI_PAGE_INSTFILES
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfReinstall
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_COMPONENTS
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_UNPAGE_FINISH
!insertmacro MUI_LANGUAGE English
; Installer log
; Every run appends a step-by-step trace to a file so a failed upgrade on a
; user's machine can be diagnosed after the fact. Kept in $TEMP rather than in
; $INSTDIR because the previous version's uninstaller removes the whole install
; directory part-way through an upgrade. Read it back with:
; notepad %TEMP%\Cockatrice-installer.log
!macro CockLog text
Push "${text}"
Call CockLogWrite
!macroend
; 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 == ""
Return
${EndIf}
; FileOpen places the pointer at the start of the file in every mode, so
; 'a' alone would overwrite the log from the beginning on each line. Seek
; past the existing content first to actually append.
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\<mask>. Used to record the DLL inventory
; before and after the upgrade purge, which is what shows whether Qt6Gui.dll was
; on disk and what (if anything) survived the deletes.
!macro CockLogListing label mask
Push "${label}"
Push "$INSTDIR\${mask}"
Call CockLogListing
!macroend
Function CockLogListing
Pop $LogMatchPattern
Pop $LogMatchLabel
!insertmacro CockLog "listing [$LogMatchLabel] pattern=$LogMatchPattern"
FindFirst $LogFindHandle $LogFindName "$LogMatchPattern"
${If} $LogFindHandle == ""
!insertmacro CockLog "listing [$LogMatchLabel] (no matches)"
Return
${EndIf}
log_listing_loop:
!insertmacro CockLog "listing [$LogMatchLabel] $LogFindName"
ClearErrors
FindNext $LogFindHandle $LogFindName
IfErrors 0 log_listing_loop
FindClose $LogFindHandle
FunctionEnd
Function .onInit
StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log"
; No backup has been taken yet; RestoreUserDataAfterUninstall must be a no-op
; until BackupUserDataBeforeUninstall sets this after actually copying something.
StrCpy $DataBackupDir ""
StrCpy $DataBackupDone ""
StrCpy $DataBackupForDir ""
StrCpy $LooseDecksBackupDone ""
StrCpy $InstallSubdirsBackupDone ""
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 ""
; 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
FileClose $LogHandle
!insertmacro CockLog "=================================================================="
!insertmacro CockLog "installer start: @CPACK_PACKAGE_NAME@ @CPACK_PACKAGE_VERSION@"
${If} ${NSIS_IS_64_BIT} == 1 #NSIS 64bit
${IfNot} ${RunningX64}
MessageBox MB_OK|MB_ICONSTOP "This version of Cockatrice requires a 64-bit Windows system."
Abort
${EndIf}
StrCpy $NormalDestDir "$ProgramFiles64\Cockatrice"
SetRegView 64
${Else} #NSIS 32bit
${If} ${RunningX64}
MessageBox MB_OK|MB_ICONEXCLAMATION \
"You are about to install a 32-bit version of Cockatrice on a 64-bit Windows system.$\n\
We advise you to use the correct 64-bit installer instead to get around potential issues.$\n$\n\
Download from our webpage: https://cockatrice.github.io"
${EndIf}
StrCpy $NormalDestDir "$ProgramFiles\Cockatrice"
${EndIf}
StrCpy $PortableDestDir "$Desktop\CockatricePortable"
${GetParameters} $9
ClearErrors
${GetOptions} $9 "/?" $8
${IfNot} ${Errors}
MessageBox MB_ICONINFORMATION|MB_SETFOREGROUND "\
/PORTABLE : Install in portable mode$\n\
/S : Silent install$\n\
/R : Silent upgrade$\n\
/PID=%pid% : Process to wait for before replacing files (used by the in-app updater)$\n\
/D=%directory% : Specify destination directory$\n"
Quit
${EndIf}
ClearErrors
${GetOptions} $9 "/PORTABLE" $8
${IfNot} ${Errors}
StrCpy $PortableMode 1
StrCpy $0 $PortableDestDir
${Else}
StrCpy $PortableMode 0
StrCpy $0 $NormalDestDir
${If} ${Silent}
Call RequireAdmin
${EndIf}
${EndIf}
ClearErrors
${GetOptions} $9 "/R" $8
${IfNot} ${Errors}
StrCpy $ReinstallMode 1
SetSilent silent
SetAutoClose true
${Else}
StrCpy $ReinstallMode 0
${EndIf}
; /PID= carries the process id of the application that started this installer.
; The in-app updater launches the installer and only then quits itself, so
; without it the installer has to guess whether the launching process is still
; alive 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 ""
StrCpy $LauncherExited 0
ClearErrors
${GetOptions} $9 "/PID=" $8
${IfNot} ${Errors}
StrCpy $WaitPid $8
${EndIf}
${If} $InstDir == ""
; User did not use /D to specify a directory,
; we need to set a default based on the install mode
StrCpy $InstDir $0
${EndIf}
; --- Detect portable install when using /R (must come BEFORE SetModeDestinationFromInstdir) ---
${If} $ReinstallMode = 1
IfFileExists "$InstDir\portable.dat" 0 not_portable
StrCpy $PortableMode 1
Goto portable_done
not_portable:
StrCpy $PortableMode 0
portable_done:
${EndIf}
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
Call SetModeDestinationFromInstdir
!insertmacro CockLog "options: InstDir=$InstDir PortableMode=$PortableMode ReinstallMode=$ReinstallMode WaitPid=$WaitPid"
${If} ${Silent}
!insertmacro CockLog "options: running silently"
${Else}
!insertmacro CockLog "options: interactive"
${EndIf}
; Wait for the process that launched this installer to exit before anything is
; 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. 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.
${If} $ReinstallMode = 1
!insertmacro CockLog "reinstall: waiting for launcher and other instances"
Call WaitForLauncherToExit
Call EnsureAppsNotRunning
${EndIf}
${If} $ReinstallMode = 1
${AndIf} $PortableMode = 0
!insertmacro CockLog "reinstall: auto-uninstalling previous version"
Call AutoUninstallIfNeeded
${EndIf}
FunctionEnd
Function un.onInit
${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
FunctionEnd
Function RequireAdmin
UserInfo::GetAccountType
Pop $8
${If} $8 != "admin"
MessageBox MB_ICONSTOP "You need administrator rights to install Cockatrice"
SetErrorLevel 740 ;ERROR_ELEVATION_REQUIRED
Abort
${EndIf}
FunctionEnd
Function SetModeDestinationFromInstdir
${If} $PortableMode = 0
StrCpy $NormalDestDir $InstDir
${Else}
StrCpy $PortableDestDir $InstDir
${EndIf}
FunctionEnd
Function SkipIfReinstall
${If} $ReinstallMode = 1
Abort
${EndIf}
FunctionEnd
Function AutoUninstallIfNeeded
SetShellVarContext all
; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller
; normally copies itself to %TEMP% and runs from there so that it can delete its
; own directory, and the process we started exits at once. ExecWait then returns
; before the uninstall has run, and the temp copy keeps deleting files - from
; 3.1.0 and 3.1.1 on disk a blanket "RMDir /r $INSTDIR" - while the section
; below is still writing the new payload. _?= disables the copy and makes
; ExecWait block until the uninstall finishes. It must be the last argument
; and must not be quoted, even when the path contains spaces.
;
; --- 32-bit uninstall ---
SetRegView 32
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
; On the /R path the updater passes /D=<appDir>, so $INSTDIR already is the old
; install dir. Publish it for the backup/restore pair below.
StrCpy $OldInstallDir $INSTDIR
!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$OldInstallDir'"
StrCmp $R0 "" done32
DetailPrint "Removing previous version (32-bit)..."
Call BackupUserDataBeforeUninstall
ExecWait '$R0 _?=$OldInstallDir' $R5
!insertmacro CockLog "uninstall (32-bit): exit=$R5"
Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done32:
; --- 64-bit uninstall ---
${If} ${RunningX64}
SetRegView 64
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
StrCpy $OldInstallDir $INSTDIR
!insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'"
StrCmp $R0 "" done64
DetailPrint "Removing previous version (64-bit)..."
Call BackupUserDataBeforeUninstall
ExecWait '$R0 _?=$OldInstallDir' $R5
!insertmacro CockLog "uninstall (64-bit): exit=$R5"
Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done64:
${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
; installing over a directory that is still being emptied.
Function WaitForOldPayloadToDisappear
${If} $OldInstallDir == ""
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"
StrCpy $AbortReason \
"The previous version in $OldInstallDir is still there after its uninstaller ran.$\r$\n$\r$\nIf that uninstaller was cancelled or reported an error, close anything still using those files and run this installer again."
Call AbortInstall
${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}
FunctionEnd
; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall
;
; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR".
; For a portable install that deletes $INSTDIR\data (decks, settings, pictures)
; along with the payload, and for a normal install it deletes anything the user
; pointed into $INSTDIR. The fixed uninstaller preserves foreign files, but the
; destructive one is already on disk on affected machines, so stash a copy
; outside $INSTDIR before invoking it and put it back afterwards. Best effort
; only: a failed backup must never abort the install.
;
; The backup is left in %TEMP% afterwards on a best-effort basis only: the next
; installer run that takes a new backup wipes it first (see below), so it is not
; a durable second copy. Anything this backup does not cover - e.g. loose files
; with other extensions - relies on the client's startup safety backup
; (warnAboutDataPathsInProgramDir), which only exists after the first launch of
; the new client.
;
; The 32-bit and 64-bit uninstall blocks below share $TEMP\CockatriceDataBackup.
; When both are registered with different InstallLocations, backing up the second
; directory into the first one's backup would restore the first dir's files into
; the second one. BackupUserDataBeforeUninstall therefore starts over when
; $OldInstallDir changes: the previous pass already restored its own backup, so
; only its leftover %TEMP% copy is lost. RelocateRestoredDataToInstDir only sees
; the last pass, so with two different old dirs plus a new target dir the first
; dir's restored data stays in place instead of moving along - no loss, but the
; user has to copy it manually.
;
; The portable-data branch below is defensive: both callers (AutoUninstallIfNeeded
; on /R, componentsPagePre interactively) only run for non-portable installs, and
; portable installs never write the registry uninstall keys that trigger them.
; It fires only when $OldInstallDir nevertheless contains portable.dat - e.g. a
; stale registry InstallLocation pointing at a portable dir, or portable.dat added
; to a normal install dir. The loose-decks and install-subdirs branches are the
; live ones for normal installs.
Function BackupUserDataBeforeUninstall
; $OldInstallDir is the directory being uninstalled: on the /R path it equals
; $INSTDIR (updater passes /D=<appDir>), on the interactive path componentsPagePre
; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is
; still the default there (directory page comes later).
${If} $OldInstallDir == ""
StrCpy $OldInstallDir $INSTDIR
${EndIf}
; %TEMP% survives across installer runs, so a previous run's safety copy would
; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files
; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B
; from run 2) or restoring stale content after a failed copy. Wipe once per
; installer run - $DataBackupDir is empty until the first backup of this run,
; so the second call in sequence (32-bit then 64-bit uninstall) still merges.
${If} $DataBackupDir == ""
RMDir /r "$TEMP\CockatriceDataBackup"
${EndIf}
; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second
; block points at a different directory, merging would restore the first dir's
; files into the second one, so start over instead. The previous pass already
; restored its own backup; only its leftover %TEMP% copy is discarded.
; StrCmp is case-sensitive while Windows paths are not, so a casing-only
; difference triggers a harmless redundant re-backup of the same content.
${If} $DataBackupDir != ""
${AndIf} $DataBackupForDir != ""
${AndIf} $OldInstallDir != $DataBackupForDir
!insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup"
RMDir /r "$TEMP\CockatriceDataBackup"
StrCpy $DataBackupDir ""
StrCpy $DataBackupDone ""
StrCpy $LooseDecksBackupDone ""
StrCpy $InstallSubdirsBackupDone ""
${EndIf}
${If} ${FileExists} "$OldInstallDir\portable.dat"
${AndIf} ${FileExists} "$OldInstallDir\data"
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
StrCpy $DataBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)"
DetailPrint "Backing up portable user data before removing previous version..."
CreateDirectory "$DataBackupDir\data"
; Copy every top-level entry except pics (downloaded pictures are large and
; re-downloadable): copying everything first and deleting pics afterwards
; would duplicate gigabytes into %TEMP% just to throw them away.
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*"
${If} $DataBackupFindHandle != ""
backup_data_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} $DataBackupFindName != "pics"
${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*"
CreateDirectory "$DataBackupDir\data\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\"
${Else}
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\"
${EndIf}
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 backup_data_loop
FindClose $DataBackupFindHandle
${EndIf}
; Keep user-made custom pics (data\pics\CUSTOM).
${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM"
CreateDirectory "$DataBackupDir\data\pics\CUSTOM"
CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\"
${EndIf}
DetailPrint "Portable user data backup complete."
${EndIf}
; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a
; blanket *.txt copy would also grab payload-adjacent text files, while the
; deck-specific extensions are unambiguous user content.
${If} ${FileExists} "$OldInstallDir\*.cod"
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
${If} $DataBackupDir == ""
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
${EndIf}
StrCpy $LooseDecksBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying loose deck files"
DetailPrint "Backing up deck files stored in the install directory..."
CreateDirectory "$DataBackupDir\loose_decks"
CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\"
${EndIf}
; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the
; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the
; client's startup backup has never run yet, so stash every top-level directory
; that is not part of the payload. Payload dirs stay in place: they are rewritten
; by the section below, and restoring them could put stale DLLs next to the new
; executables. "data" is skipped once the portable branch above already took it;
; without portable.dat it is user content like any other unknown dir.
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*"
${If} $DataBackupFindHandle != ""
backup_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} $DataBackupFindName != "plugins"
${AndIf} $DataBackupFindName != "Plugins"
${AndIf} $DataBackupFindName != "translations"
${AndIf} $DataBackupFindName != "sounds"
${AndIf} $DataBackupFindName != "themes"
${If} $DataBackupFindName == "data"
${AndIf} $DataBackupDone == "1"
Goto backup_subdirs_next
${EndIf}
${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
${If} $DataBackupDir == ""
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
${EndIf}
StrCpy $InstallSubdirsBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName"
DetailPrint "Backing up $DataBackupFindName stored in the install directory..."
CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\"
${EndIf}
${EndIf}
backup_subdirs_next:
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 backup_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
; Remember which directory the current backup belongs to, so a later pass for a
; different directory starts over instead of merging (see above).
${If} $DataBackupDir != ""
StrCpy $DataBackupForDir $OldInstallDir
${EndIf}
FunctionEnd
Function RestoreUserDataAfterUninstall
StrCmp $DataBackupDir "" done
${If} $OldInstallDir == ""
StrCpy $OldInstallDir $INSTDIR
${EndIf}
${If} $DataBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\data"
${If} ${FileExists} "$OldInstallDir\data"
; Existence-only: a partially-deleted data dir (locked files during the old
; uninstall) looks the same as a survived one, so the backup is always kept.
; Merge the backup back over it to repair partial deletes; CopyFiles only
; adds/overwrites, so a fully-survived dir is unaffected.
!insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it"
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data."
${Else}
!insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data"
CreateDirectory "$OldInstallDir\data"
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
DetailPrint "Restored portable user data from pre-uninstall backup."
${EndIf}
${EndIf}
${If} $LooseDecksBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\loose_decks"
!insertmacro CockLog "post-uninstall restore: restoring loose deck files"
CreateDirectory "$OldInstallDir"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\"
DetailPrint "Restored deck files stored in the install directory."
${EndIf}
${If} $InstallSubdirsBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
!insertmacro CockLog "post-uninstall restore: restoring install subdirectories"
CreateDirectory "$OldInstallDir"
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
${If} $DataBackupFindHandle != ""
restore_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*"
!insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName"
CreateDirectory "$OldInstallDir\$DataBackupFindName"
CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\"
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 restore_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
DetailPrint "Restored folders stored in the install directory."
${EndIf}
; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the
; notice a single time per installer run, covering both loose decks and
; restored subdirectories.
${IfNot} ${Silent}
${AndIf} $LooseDeckNoticeShown != "1"
${If} $LooseDecksBackupDone == "1"
${OrIf} $InstallSubdirsBackupDone == "1"
StrCpy $LooseDeckNoticeShown "1"
MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again."
${EndIf}
${EndIf}
done:
FunctionEnd
; RelocateRestoredDataToInstDir
;
; componentsPagePre backs up and restores around the old uninstaller before the
; directory page, so $OldInstallDir (registry InstallLocation) is authoritative
; there while $INSTDIR is still the default. When the user then picks a different
; target directory, the restored user data would otherwise stay orphaned in the
; old directory while the new install starts empty. Copy it across when they differ
; (a copy, not a move: the source is user data and is deliberately left in place).
; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup
; comment above); with two different old dirs the first one's data stays restored
; in place instead of moving along.
; StrCmp is case-sensitive while Windows paths are not; a casing-only difference
; triggers a harmless redundant copy onto itself.
Function RelocateRestoredDataToInstDir
StrCmp $DataBackupDir "" done
StrCmp $OldInstallDir "" done
StrCmp $OldInstallDir $INSTDIR done
${If} $DataBackupDone == "1"
${AndIf} ${FileExists} "$OldInstallDir\data"
!insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data"
CreateDirectory "$INSTDIR\data"
CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\"
DetailPrint "Copied restored user data to the new install folder."
${EndIf}
${If} $LooseDecksBackupDone == "1"
${If} ${FileExists} "$OldInstallDir\*.cod"
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
!insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR"
CreateDirectory "$INSTDIR"
CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\"
DetailPrint "Copied restored deck files to the new install folder."
${EndIf}
${EndIf}
${If} $InstallSubdirsBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
${If} $DataBackupFindHandle != ""
relocate_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
!insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName"
CreateDirectory "$INSTDIR\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\"
DetailPrint "Copied restored folder $DataBackupFindName to the new install folder."
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 relocate_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
${EndIf}
done:
FunctionEnd
; --- Running instance handling ---
; Cockatrice, Oracle and Servatrice must not be running while files are
; replaced or deleted. A still-running process holds locks on its .exe and
; Qt runtime DLLs, so a silent upgrade could otherwise end up with a mix of
; old and new Qt DLLs next to the new executable, failing with
; "The procedure entry point X could not be located in the dynamic link
; library ...Qt6Network.dll" on the next start.
;
; 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
; process whose image lives in the directory being replaced.
;
; 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 (WM_CLOSE), made automatically once because that is what
; the user started the installer to do, and only Cockatrice can answer one - the other
; two have no window. When the close is not answered the user is told which program is
; in the way and is offered to end all three themselves - the last resort, with what it
; costs spelled out in the prompt.
; 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."
; The other half of that answer - what is left to do when nothing is ended - is shared by
; every prompt that names the holders, for the same reason.
!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."
; usage: set the variable named by the second argument to the probe's listing, then call
; this. Sets $GuardPrompt to the question the guard, the uninstaller's guard and the
; recovery path all 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.
;
; 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 and the sentence only a failed end adds, 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 Cancel gives up, "installation" or "uninstallation"
!macro CockEndPrompt header listing cancelWord
StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}"
; Said only after the previous answer was Yes: without it the same dialog reappears word
; for word, which is what "nothing happened" looked like - the end can fail on a process
; the installer may not open, and it never touches a program that is not one of ours.
${If} $GuardEndFailed = 1
StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nEnding them did not free the files."
${EndIf}
StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to try again after closing anything else listed above by hand. ${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nChoose Cancel to cancel 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
; 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 nothing runs from the directory, 1 = the
; line names what does, anything else = the probe could not answer (2 = no
; directory was given, 3 = the Restart Manager call failed and no process 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
; 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 processes running from the directory. tools\installer-holder-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 brought to the foreground before WM_CLOSE is posted to its main window.
; WM_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")'
; 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
; 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")'
; 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
!insertmacro CockHolderProbeFunctions "" install
!insertmacro CockHolderProbeFunctions "un." uninstall
; usage: set $GuardDir to the directory to guard, then call this.
;
; Asks Cockatrice to close once, then prompts until the payload is free. 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 Cockatrice may still be waiting for an answer in
; its own confirmation. It offers to end the programs the payload installs - and only those,
; which the text says - with what that costs (Yes), a plain re-check for after the user
; closed things by hand (No), and Cancel - 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 $GuardCloseTried 0
StrCpy $GuardEndFailed 0
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} $GuardExit == "1"
!insertmacro CockLog "guard: still in use by $GuardOutput"
; Close it for the user first. The probe knows exactly which cockatrice.exe holds
; these files, and asking it to close is the same request as clicking the window's
; close button - the application still answers its own "really quit?" prompt, so
; nothing is lost. One attempt per guard call keeps the common case (the client was
; still shutting down) from turning into a question, without hiding the case where
; the close did not help: the pass after it names what is still holding the files.
${If} $GuardCloseTried = 0
StrCpy $GuardCloseTried 1
!insertmacro CockLog "guard: asking cockatrice to close, then checking again"
Call CloseHolders
Goto guard_check
${EndIf}
DetailPrint "The install directory is in use by: $GuardOutput"
; The automatic close already ran once. Asking Cockatrice to close again is unlikely
; to help: an instance that did not answer the first one is usually sitting on a
; confirmation of its own, and the close probe leaves such an instance alone so that a
; second WM_CLOSE cannot hide the window the question is in - which is how a pre-3.1.2
; update used to leave a headless process behind. An instance with no window has
; nothing to ask at all. Ending is what is left that can free the files, so the prompt
; offers it here, with what it costs in the text: an unsaved deck, a game, a settings
; write, and an Oracle that may be mid-way through the card database. The answers that
; end nothing stay reachable from the same dialog: the instance's own confirmation,
; and closing anything by hand.
!insertmacro CockEndPrompt "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_check
!insertmacro CockLog "guard: cancelled by user"
StrCpy $AbortReason \
"The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again."
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
; Everything the payload installs is ended, but a program that could not be opened
; (another account, a protected process) survives, and a program that is not one of
; ours is never touched at all, so what is left may still have to be closed by hand.
; Go back to the same prompt, which says the end did not free the files.
StrCpy $GuardEndFailed 1
Goto guard_check
${EndIf}
; Not an answer at all: the probe was not configured, the Restart Manager
; call failed and nothing runs from the directory, or it 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), 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 check whether Cockatrice is still running: $GuardOutput"
MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
"This installer could not check 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 check 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."
Call AbortInstall
FunctionEnd
Function EnsureAppsNotRunning
; guards the install directory
StrCpy $GuardDir $INSTDIR
Call GuardAppsNotRunning
FunctionEnd
; Aborts the installation, showing $AbortReason to the user.
;
; Every abort goes through here so 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"
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.
; 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
; 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 "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 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 holder probe.
Function WaitForLauncherToExit
StrCpy $LauncherExited 0
!insertmacro CockLog "wait-launcher: pid='$WaitPid'"
${If} $WaitPid == ""
!insertmacro CockLog "wait-launcher: no pid given, falling back to name check"
Return
${EndIf}
; 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
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
; passed straight to WaitForSingleObject. Fold a plugin failure into the same
; null-handle case instead of reading a handle that was never written.
${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. 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 ..."
; WAIT_OBJECT_0 is 0; WAIT_TIMEOUT is 258 and a failed wait reports 0xFFFFFFFF.
System::Call 'kernel32::WaitForSingleObject(p R0, i $WaitSlice) i.R1'
${If} $R1 != 0
; $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."
; Same three answers as every other prompt that waits on a running program, and
; "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, No asks it to close again and keeps
; waiting, Cancel cancels - the launcher is the client the user started this update
; from, and ending it is the same offer the guard makes a moment later.
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 bring it to the front, ask it to close again and 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.
StrCpy $WaitSlice 60000
; The same request the window's close button makes, and the same close the guard
; would send a moment later. It brings the client forward as well, which is the
; part that helps when the confirmation is behind something else. Nothing is
; force-stopped here: the answer that does that is Yes.
StrCpy $GuardDir $INSTDIR
Call CloseHolders
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
FunctionEnd
Function EnsureOldInstallNotRunning
; Guards the directory of a *previous* installation, as recorded in the
; registry. Needed by componentsPagePre, which runs the old uninstaller
; before the directory page, so $INSTDIR is still the default there and not
; the directory the old install lives in. Every uninstaller shipped before
; this change had no running instance guard of its own, so the old one would
; delete files while an instance of the application is still holding them.
ReadRegStr $R3 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "InstallLocation"
${If} $R3 == ""
; no usable record of where the old install is; $INSTDIR is the default
; install directory at this point, which is where it usually is anyway
DetailPrint "Could not read the previous installation directory from the registry; checking $INSTDIR instead."
StrCpy $R3 $INSTDIR
${EndIf}
StrCpy $GuardDir $R3
Call GuardAppsNotRunning
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
; install that only ever held e.g. servatrice.exe is still purged.
Function IsExistingCockatriceInstall
StrCpy $R0 0
${If} ${FileExists} "$INSTDIR\cockatrice.exe"
StrCpy $R0 1
${EndIf}
${If} ${FileExists} "$INSTDIR\oracle.exe"
StrCpy $R0 1
${EndIf}
${If} ${FileExists} "$INSTDIR\servatrice.exe"
StrCpy $R0 1
${EndIf}
${If} ${FileExists} "$INSTDIR\uninstall.exe"
StrCpy $R0 1
${EndIf}
; A leftover runtime DLL counts as an install as well. A previously failed
; update can leave a directory in which the old uninstaller removed the
; executables and uninstall.exe but silently skipped the DLLs its running
; instance had locked - which is exactly the broken install the purge is
; meant to heal, and without these markers none of the files above would be
; there to prove that it is one. Every Qt module Cockatrice links is part of
; the payload, so match them all: probing only Qt6Core.dll would miss a
; directory where that one file happened to be deletable and the others
; were not. Qt6*.dll rather than *.dll on purpose: this function decides
; whether a directory is ours, and a folder that merely holds some DLLs of
; its own must not be purged. Qt6*.dll is enough even when only the protobuf
; runtime survived, because the whole Qt set is mapped by the same process
; that maps abseil_dll.dll and so is locked or free with it.
StrCpy $R3 "Qt6*.dll"
Call CheckStaleRuntimeMask
${If} ${FileExists} "$INSTDIR\qt.conf"
StrCpy $R0 1
${EndIf}
FunctionEnd
; Sets $R0 to 1 if a runtime file of a previous version is still present after
; the purge. NSIS silently skips whatever it cannot delete, so one surviving file
; is enough to prove that something still holds a lock on the install directory.
;
; Any DLL at all counts, not a list of expected ones. The purge removes every
; top-level DLL, so after it has run the only DLL that can still be there is one
; it failed to delete, whatever its name. A mask per library family (Qt, then
; OpenSSL, then zlib) is what let abseil_dll.dll through: it is in neither the
; purge nor this check, so it survived a 3.0.2 to 3.1 update untouched, the new
; executables were written next to it, and the client then failed to start with
; "abseil_dll.dll is either not designed to run on Windows or it contains an
; error". Probing *.dll has no list to fall out of date when the next
; transitive dependency appears.
Function HasStaleRuntimeFiles
StrCpy $R0 0
StrCpy $R3 "*.dll"
Call CheckStaleRuntimeMask
; IfFileExists takes wildcards, so a Plugins directory the purge could not
; remove counts as a leftover too.
${If} ${FileExists} "$INSTDIR\Plugins\*.*"
!insertmacro CockLog "stale-runtime: Plugins directory survived the purge"
StrCpy $R0 1
${EndIf}
FunctionEnd
; usage: set $R3 to a file mask, call this. Sets $R0 to 1 when $INSTDIR holds a
; matching file. Returns early once something was found, so $R0 stays 1.
Function CheckStaleRuntimeMask
${If} $R0 = 1
Return
${EndIf}
FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\$R3"
${If} $StaleFindHandle != ""
!insertmacro CockLog "stale-runtime: '$StaleFindName' matched $R3 in $INSTDIR"
StrCpy $R0 1
FindClose $StaleFindHandle
${EndIf}
FunctionEnd
; 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}
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}
StrCpy $R0 "windows error"
${EndIf}
FunctionEnd
; 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
; 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 process running from the directory, 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; then the per-file codes logged
; above are the whole story, and the probe says so by naming nothing rather than by
; failing.
;
; $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 program is running from the directory"
${Else}
StrCpy $HolderOutput "the holder probe could not run: $HolderOutput"
${EndIf}
!insertmacro CockLog "holders: $HolderOutput"
FunctionEnd
; 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.
;
; Every round offers all three answers: Yes ends the programs the payload installs - and
; says so, because anything else the probe named is left to the user - and purges again, No
; purges again without ending anything (for when the user closed things by hand), and
; Cancel gives up. Each round re-reads the holders, so the prompt names what survived the
; last attempt rather than repeating the first listing.
Function RecoverFromHolders
${If} $HolderFound != 1
!insertmacro CockLog "recover-holders: no holder was named, nothing to offer"
Return
${EndIf}
StrCpy $GuardEndFailed 0
; 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
Goto rfh_retry
rfh_prompt:
; The same question the guard asks, so the two cannot drift apart in what they promise.
!insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation"
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end IDNO rfh_retry
!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
; The next prompt says the end did not free the files, instead of naming the same
; survivors word for word.
StrCpy $GuardEndFailed 1
rfh_retry:
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 next prompt and the log describe what
; survived the last attempt and not what was there before it.
Call LogStaleRuntimeDiagnostics
${If} $HolderFound != 1
Return
${EndIf}
Goto rfh_prompt
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
Call un.GuardAppsNotRunning
FunctionEnd
Function un.GuardAppsNotRunning
StrCpy $GuardCloseTried 0
StrCpy $GuardEndFailed 0
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}
${If} $GuardExit == "1"
!insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput"
; Same convenience as in the installer: ask Cockatrice to close once, then report
; what the close could not reach.
${If} $GuardCloseTried = 0
StrCpy $GuardCloseTried 1
!insertmacro CockLogUn "uninst-guard: asking cockatrice to close, then checking again"
Call un.CloseHolders
Goto un_guard_check
${EndIf}
DetailPrint "The install directory is in use by: $GuardOutput"
; Same approach as in the installer's guard: the automatic close already ran once, and
; asking again is unlikely to help an instance that is sitting on its own confirmation
; or has no window a close could reach. Ending is what is left that can free the files,
; so the prompt offers it here, with what it costs in the text, while keeping the
; answers that end nothing - the instance's own question, and closing anything by hand
; - reachable from the same dialog. CockEndPrompt words it, so this copy and the
; installer's promise the same thing.
!insertmacro CockEndPrompt "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_check
!insertmacro CockLogUn "uninst-guard: cancelled by user"
SetErrorLevel 1
Abort
; 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
; Everything the payload installs is ended, but a program that could not be opened
; (another account, a protected process) survives, and a program that is not one of
; ours is never touched at all, so what is left may still have to be closed by hand.
; Go back to the same prompt, which says the end did not free the files.
StrCpy $GuardEndFailed 1
Goto un_guard_check
${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 can simply be retried.
!insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput"
DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput"
MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
"This uninstaller could not check 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
${If} $ReinstallMode = 1
Abort
${EndIf}
Call SetModeDestinationFromInstdir ; If the user clicks BACK on the directory page we will remember their mode specific directory
!insertmacro MUI_HEADER_TEXT "Install Mode" "Choose how you want to install Cockatrice."
nsDialogs::Create 1018
Pop $0
${NSD_CreateLabel} 0 10u 100% 24u "Select install mode:"
Pop $0
${NSD_CreateRadioButton} 30u 50u -30u 8u "Normal installation"
Pop $1
${NSD_CreateRadioButton} 30u 70u -30u 8u "Portable mode (all files in a single folder)"
Pop $2
${If} $PortableMode = 0
SendMessage $1 ${BM_SETCHECK} ${BST_CHECKED} 0
${Else}
SendMessage $2 ${BM_SETCHECK} ${BST_CHECKED} 0
${EndIf}
nsDialogs::Show
FunctionEnd
Function PortableModePageLeave
${NSD_GetState} $1 $0
${If} $0 <> ${BST_UNCHECKED}
StrCpy $PortableMode 0
StrCpy $InstDir $NormalDestDir
Call RequireAdmin
${Else}
StrCpy $PortableMode 1
StrCpy $InstDir $PortableDestDir
${EndIf}
FunctionEnd
Function componentsPagePre
${If} $ReinstallMode = 1
Return
${EndIf}
${If} $PortableMode = 0
SetShellVarContext all
# uninstall 32bit version
SetRegView 32
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "UninstallString"
StrCmp $R0 "" done32
${If} $ReinstallMode = 0
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst32
; 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}
uninst32:
; the old uninstaller is run here, before the directory page, and every
; uninstaller predating this change has no guard of its own: make sure no
; instance of the application is still running out of the *old* install
; directory before letting it delete anything. The uninstall string is
; stashed away first, the check works on $R0.
; $INSTDIR is still the default here (directory page comes later), so the
; backup/restore dir comes from the registry InstallLocation via $GuardDir,
; not from $INSTDIR - otherwise a custom-location install is unprotected.
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" $R5
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5"
Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done32:
# uninstall 64bit version
${If} ${NSIS_IS_64_BIT} == 1
SetRegView 64
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "UninstallString"
StrCmp $R0 "" done64
${If} $ReinstallMode = 0
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst64
!insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt"
Quit
${Else}
Goto uninst64
${EndIf}
uninst64:
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" $R5
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5"
Call WaitForOldPayloadToDisappear
Call RestoreUserDataAfterUninstall
done64:
${EndIf}
${Else}
Abort
${EndIf}
FunctionEnd
Section "Application" SecApplication
SetShellVarContext all
SetOutPath "$INSTDIR"
!insertmacro CockLog "section: InstDir=$INSTDIR PortableMode=$PortableMode ReinstallMode=$ReinstallMode"
; No application instance may be running while any file below is deleted or
; replaced: both upgrade blocks start by removing the old executables and
; runtime DLLs, and NSIS silently skips whatever is locked, which is how an
; install ends up with a mix of old and new runtimes next to the new executable.
; The next start then fails with "The procedure entry point X could not be
; located in the dynamic link library ...Qt6Network.dll" for the Qt ones, or
; "abseil_dll.dll is either not designed to run on Windows or it contains an
; error" for the protobuf ones. So the check has to come before the first
; Delete/RMDir and not after it: an interactive install into an existing portable
; directory with cockatrice.exe still running would otherwise delete the old
; files under its feet.
;
; It is skipped on the /R path: .onInit already ran it before
; AutoUninstallIfNeeded removed the previous installation, so by the time this
; section starts the old files are gone and a second Abort here would leave the
; user with the old version uninstalled and the new one not installed at all,
; which is worse than the locked files being prevented. The leftover runtime
; files are reported at the end of the purge instead.
${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
!insertmacro CockLog "section: portable upgrade purge"
!insertmacro CockLogListing "before-portable-purge" "*.dll"
; plugins and translations are payload and are rewritten by the payload
; below, so removing them here is safe. sounds and themes are payload
; too - the default packs are rewritten below as well - but a portable
; user's custom sounds and themes live in exactly those folders, so a
; recursive purge here would delete user content along with the defaults.
; Stale default files from an older release are overwritten or replaced
; by the payload, never by a purge.
RMDir /r "$INSTDIR\plugins"
RMDir /r "$INSTDIR\translations"
Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\cockatrice.exe"
Delete "$INSTDIR\oracle.exe"
Delete "$INSTDIR\servatrice.exe"
; Every top-level DLL, not a list of expected ones. The payload is written
; after this block, so a DLL the new build still needs is put straight back;
; 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.
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"
Delete "$INSTDIR\servatrice.ini.example"
RMDir "$INSTDIR"
!insertmacro CockLogListing "after-portable-purge" "*.dll"
${EndIf}
; Remove any runtime DLLs left over from older versions, so a mismatched
; Qt/OpenSSL/protobuf set cannot survive next to the new executables.
;
; 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} $IsUpgrade = 1
!insertmacro CockLog "section: non-portable upgrade purge"
!insertmacro CockLogListing "before-purge" "*.dll"
Call RetryDllPurge
StrCpy $StaleRuntimeAfterPurge $R0
StrCpy $DllPurgeRan 1
!insertmacro CockLogListing "after-purge" "*.dll"
!insertmacro CockLogListing "after-plugins" "Plugins\*.*"
${EndIf}
; Whatever was locked during the purge (by an instance that started while the
; installer was running, or because the running instance check could not be
; performed at all) is still on disk, and the new executables are about to be
; written next to it. That mix is exactly what makes the next start fail with
; "The procedure entry point X could not be located in the dynamic link library
; ...Qt6QuickWidgets.dll", "The code execution cannot proceed because
; Qt6Multimedia.dll was not found", or "abseil_dll.dll is either not designed to
; 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
${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 $StaleRuntimeAfterPurge 0
${EndIf}
${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 or to try the removal again; this updates $StaleRuntimeAfterPurge
; with the last retry's verdict.
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."
; 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, 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}
@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@
!insertmacro CockLog "section: writing payload to $INSTDIR"
@CPACK_NSIS_FULL_INSTALL@
!insertmacro CockLog "section: payload written"
; The pre-uninstall backup/restore above runs before the directory page, so a
; user picking a different target folder would otherwise leave the restored data
; orphaned in the old directory. Move it across when they differ (no-op on the
; /R path where they are equal).
Call RelocateRestoredDataToInstDir
!insertmacro CockLogListing "final" "Qt6*.dll"
${If} $PortableMode = 0
WriteUninstaller "$INSTDIR\uninstall.exe"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
; No Windows Error Reporting LocalDumps registration: crash capture belongs to
; the in-app reporter (Sentry/breakpad, MiniDumpNormal-class data only). Opting
; machines into OS-level full dumps (DumpType 2) would leave unredacted memory
; dumps on user machines that nothing consumes.
; https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps
; Older installers did register cockatrice.exe above (and the removed
; MiniDumpWithFullMemory writer filled that folder), and that key survives this
; install, so WER would keep writing full dumps. Remove it, and clear the
; legacy dumps nothing consumes anymore. $LOCALAPPDATA resolves per shell context:
; under "all" it points at ProgramData, so switch to "current" for the per-user
; dumps folder and back afterwards; the registry delete works in either context.
DeleteRegKey HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe"
SetShellVarContext current
RMDir /r "$LOCALAPPDATA\CrashDumps\Cockatrice"
SetShellVarContext all
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "DisplayIcon" "$INSTDIR\cockatrice.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "DisplayName" "Cockatrice"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "DisplayVersion" "@CPACK_PACKAGE_VERSION_MAJOR@.@CPACK_PACKAGE_VERSION_MINOR@.@CPACK_PACKAGE_VERSION_PATCH@"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "EstimatedSize" "$0"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "InstallLocation" "$INSTDIR"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "NoModify" "1"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "NoRepair" "1"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "Publisher" "Cockatrice team"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMajor" "@CPACK_PACKAGE_VERSION_MAJOR@"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMinor" "@CPACK_PACKAGE_VERSION_MINOR@"
; --- Register .cod file type ---
WriteRegStr HKCR ".cod" "" "Cockatrice"
WriteRegStr HKCR "Cockatrice" "" "Cockatrice Deck File"
WriteRegStr HKCR "Cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"'
; --- Register custom URI protocol ---
WriteRegStr HKCR "cockatrice" "" "URL: Cockatrice Protocol"
WriteRegStr HKCR "cockatrice" "URL Protocol" ""
WriteRegStr HKCR "cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"'
IfFileExists "$INSTDIR\vc_redist.x86.exe" VcRedist86Exists PastVcRedist86Check
VcRedist86Exists:
ExecWait '"$INSTDIR\vc_redist.x86.exe" /passive /norestart'
DetailPrint "Wait to ensure unlock of vc_redist file after installation..."
Sleep 3000
Delete "$INSTDIR\vc_redist.x86.exe"
PastVcRedist86Check:
IfFileExists "$INSTDIR\vc_redist.x64.exe" VcRedist64Exists PastVcRedist64Check
VcRedist64Exists:
ExecWait '"$INSTDIR\vc_redist.x64.exe" /passive /norestart'
DetailPrint "Sleep to ensure unlock of vc_redist file after installation..."
Sleep 3000
Delete "$INSTDIR\vc_redist.x64.exe"
PastVcRedist64Check:
${Else}
; Create the file the application uses to detect portable mode
FileOpen $0 "$INSTDIR\portable.dat" w
FileWrite $0 "PORTABLE"
FileClose $0
${EndIf}
${If} $ReinstallMode = 1
IfFileExists "$INSTDIR\cockatrice.exe" 0 +2
Exec '"$INSTDIR\cockatrice.exe"'
${EndIf}
!insertmacro CockLog "section: application install complete"
SectionEnd
Section "Start menu item" SecStartMenu
${If} $PortableMode = 0
SetShellVarContext all
createDirectory "$SMPROGRAMS\Cockatrice"
createShortCut "$SMPROGRAMS\Cockatrice\Cockatrice.lnk" "$INSTDIR\cockatrice.exe"
createShortCut "$SMPROGRAMS\Cockatrice\Oracle.lnk" "$INSTDIR\oracle.exe"
createShortCut "$SMPROGRAMS\Cockatrice\Servatrice.lnk" "$INSTDIR\servatrice.exe"
${EndIf}
SectionEnd
Section "un.Application" UnSecApplication
SetShellVarContext all
StrCpy $R1 0
; Record whether this was a portable install before portable.dat is
; removed below.
IfFileExists "$INSTDIR\portable.dat" 0 +2
StrCpy $R1 1
; Remove only what the installer wrote. The two lists below are generated
; by CPack from the staged install tree: one Delete per installed file and
; one non-recursive RMDir per installed directory, children first.
; Anything the user put in $INSTDIR - decks, pictures, portable data
; folders - is not on those lists and survives, and $INSTDIR itself is
; only removed when nothing else is left in it. An earlier version of this
; section ran a blanket "RMDir /r $INSTDIR" to keep build-tree artifacts
; out of a fresh reinstall; that also deleted user decks whenever a user
; had pointed their data folders into the install directory, so payload
; purity may never be bought at that price again.
@CPACK_NSIS_DELETE_FILES@
@CPACK_NSIS_DELETE_DIRECTORIES@
; Written by the installer itself, so absent from the staged payload
; above; qdebug.txt is app-generated log output next to the executables.
Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\portable.dat"
Delete "$INSTDIR\qdebug.txt"
; Succeeds only when no foreign files remain.
RMDir "$INSTDIR"
RMDir /r "$SMPROGRAMS\Cockatrice"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
; Leftover WER LocalDumps key from pre-Sentry installers (see the installer note
; above): removing it here covers machines that uninstall without reinstalling.
; The legacy full-memory dumps go with it; nothing consumes them anymore. Same shell
; context note as the installer: the per-user dumps folder needs "current".
DeleteRegKey HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe"
SetShellVarContext current
RMDir /r "$LOCALAPPDATA\CrashDumps\Cockatrice"
SetShellVarContext all
; Only remove the file/protocol associations if we registered them (i.e. the
; install was not portable) and .cod is still owned by Cockatrice, so we don't
; clobber a .cod association installed by another application.
${If} $R1 == 0
ReadRegStr $0 HKCR ".cod" ""
${If} $0 == "Cockatrice"
DeleteRegKey HKCR ".cod"
DeleteRegKey HKCR "Cockatrice"
DeleteRegKey HKCR "cockatrice"
${EndIf}
${EndIf}
SectionEnd
; unselected because it is /o
Section /o "un.Configurations, decks, cards, pics" UnSecConfiguration
SetShellVarContext current
DeleteRegKey HKCU "Software\Cockatrice"
RMDir /r "$LOCALAPPDATA\Cockatrice"
SectionEnd
LangString DESC_SecApplication ${LANG_ENGLISH} "Cockatrice program files"
LangString DESC_SecStartMenu ${LANG_ENGLISH} "Create start menu items for Cockatrice and Oracle."
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecApplication} $(DESC_SecApplication)
!insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} $(DESC_SecStartMenu)
!insertmacro MUI_FUNCTION_DESCRIPTION_END
LangString DESC_UnSecApplication ${LANG_ENGLISH} "Cockatrice program files and start menu items"
LangString DESC_UnSecConfiguration ${LANG_ENGLISH} "Configurations, decks, card database, pictures"
!insertmacro MUI_UNFUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${UnSecApplication} $(DESC_UnSecApplication)
!insertmacro MUI_DESCRIPTION_TEXT ${UnSecConfiguration} $(DESC_UnSecConfiguration)
!insertmacro MUI_UNFUNCTION_DESCRIPTION_END