Cockatrice/cmake/nsis/runtime-purge.nsh
Lukas Brübach 2ea11d4599 [Installer] Split the NSIS template into includes
Pure refactor, no behavior change.

cmake/NSIS.template.in is the CPack template, and CPack substitutes the @...@
placeholders in that file only, so everything without a substitution moves into
cmake/nsis/ and is included from it:

  installer-log.nsh   the CockLog macro and the two writers behind it
  process-guard.nsh   running instance handling, installer and uninstaller copy
  user-data.nsh       backup/restore around the previous version's uninstaller
  runtime-purge.nsh   stale runtime purge, diagnostics and the holder probe

The page and section order, the Vars the included files share, .onInit (it names
the package version in the log), AbortInstall (its Sentry offer is substituted),
componentsPagePre and the registry bookkeeping stay in the template.

The includes resolve through NSIS_SOURCE_PATH, which the template already uses
for the bitmaps, and an include does not change the compile directory, so the
holder probe keeps its relative File path.
2026-11-02 21:19:09 +01:00

439 lines
22 KiB
NSIS

; Stale runtime purge and its diagnostics: which leftovers make an install
; unusable, the retry loop that removes them, the read-only clearing that has to
; happen first, and the Restart Manager probe that names whatever survived.
;
; Everything here runs between the running-instance guard and the payload write,
; and none of it aborts on its own: the template decides what to do with the
; verdict.
;
; Register contract:
; $R0 out: RetryDllPurge/HasStaleRuntimeFiles/IsExistingCockatriceInstall
; verdict (1 = leftovers or an existing install, 0 = neither)
; $R5 internal: RetryDllPurge retry counter
; $R6-$R8 internal: ClearReadOnlyTree recursion, saved and restored
; $HolderExit/$HolderOutput/$HolderFound
; out: LogProcessesUsingInstallDir - exit code, the one-line summary
; the dialogs show, and 1 when it named a holder
; $PLUGINSDIR\installer-holder-probe.exe
; built by tools/installer-holder-probe and embedded under the same
; relative path NSIS.definitions.nsh resolves to the build root
; Sets $R0 to 1 if $INSTDIR looks like an existing Cockatrice install. Used to
; 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 payload program running from the directory
; itself - not one in a folder below it, which is another application the user installed
; there - because a running instance holds its payload as mapped images rather than as open
; handles and the Restart Manager does not reliably report those. Its remaining blind spot
; is a handle taken in kernel mode, where there is no process to name; a payload executable
; among the files then still refuses to be replaced, and the probe answers with that file
; instead of naming nothing.
;
; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard
; uses, so a path with spaces or apostrophes never has to be embedded in a command line.
Function LogProcessesUsingInstallDir
StrCpy $HolderExit 0
StrCpy $HolderOutput ""
StrCpy $HolderFound 0
; The directory that just failed the purge, not whatever a check before the directory
; page looked at: a portable install into a custom folder reaches here with the two
; being different.
StrCpy $GuardDir $INSTDIR
Call RunHolderProbe
StrCpy $HolderExit $GuardExit
StrCpy $HolderOutput $GuardOutput
; The exit code, not the text, decides what happened: it cannot be mangled by what
; nsExec leaves on the end of the captured output. 1 = the line below names what is
; holding the files, 0 = nothing holds them any more, anything else = the probe could
; not answer, and says why in its own output.
${If} $HolderExit == "1"
StrCpy $HolderFound 1
${ElseIf} $HolderExit == "0"
StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory"
${Else}
StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput"
${EndIf}
!insertmacro CockLog "holders: $HolderOutput"
FunctionEnd
; Called after a purge left files behind and LogStaleRuntimeDiagnostics named who is
; holding them. Instead of aborting straight away - which on /R strands the user, since
; the old version is already uninstalled - it closes once, purges again, and only then
; asks: end what is still holding the files, or try the removal again by hand. Sets
; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts when the
; files survive.
;
; The shape is the guard's, deliberately: one automatic attempt at a graceful close, then a
; prompt whose Yes ends the payload programs and whose No retries. A client that was still
; shutting down releases the files in the automatic round and never sees a question, and an
; Oracle or Servatrice - which a close cannot reach - is what the prompt exists for, so the
; user is never left with a dialog whose answers cannot free anything.
;
; The automatic close is a request (WM_CLOSE) and the client keeps the last word: it may be
; waiting for an answer to its own "really quit?" confirmation (a running card database
; update, an open game, an unsaved deck), and that stays its decision. The instance is
; brought forward first, so that confirmation is on screen rather than behind this
; installer's prompt.
;
; Ending is the same last resort the guard offers, with the same cost spelled out, and only
; ever the programs the payload installs - see EndHolders.
;
; Restart Manager's RmShutdown was the alternative and was rejected: it shuts down
; services as well as applications, and the only way to bring them back is RmRestart,
; which would restart this very application into files that are about to be replaced.
;
; Always offered, in every mode: a /S install without /R is the only case without someone
; at the keyboard, and this installer does not promise unattended runs - if an error needs
; a decision, it asks for one. No offer when the probe named nobody: there is nothing to
; end, and the abort's per-file diagnostics already record what Windows said.
;
; One question after the automatic close, and never a loop: Yes ends the programs the
; payload installs - and says so, because anything else the probe named is left to the user -
; and purges again; the other answer gives up, and the caller's abort names what survived.
; The listing is re-read before the question, so it says what is left now rather than
; repeating the first one.
Function RecoverFromHolders
${If} $HolderFound != 1
!insertmacro CockLog "recover-holders: no holder was named, nothing to offer"
Return
${EndIf}
; The automatic attempt, the same one the guard makes before it asks anything.
!insertmacro CockLog "recover-holders: closing cockatrice, then trying the removal again"
StrCpy $GuardDir $INSTDIR
Call CloseHolders
Call RetryDllPurge
StrCpy $StaleRuntimeAfterPurge $R0
!insertmacro CockLogListing "after-retry-purge" "*.dll"
!insertmacro CockLogListing "after-retry-plugins" "Plugins\*.*"
!insertmacro CockLog "recover-holders: stale-runtime after retry=$StaleRuntimeAfterPurge"
${If} $StaleRuntimeAfterPurge = 0
DetailPrint "Files of the previous version were removed; continuing the installation."
Return
${EndIf}
; Still stuck: refresh the names, so the prompt and the log describe what survived the
; last attempt and not what was there before it.
Call LogStaleRuntimeDiagnostics
${If} $HolderFound != 1
Return
${EndIf}
; One question, the same one the guard asks and with the same two answers. No retry loop:
; the purge has already been tried after the close, so the only thing that can still free
; the files is ending them, and the caller's abort names whatever survives that.
!insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation"
MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end
!insertmacro CockLog "recover-holders: cancelled by user"
Return
; Only reached through Yes: Return does not come back here.
rfh_end:
!insertmacro CockLog "recover-holders: ending the payload programs on request, then trying the removal again"
Call EndHolders
Call RetryDllPurge
StrCpy $StaleRuntimeAfterPurge $R0
!insertmacro CockLog "recover-holders: stale-runtime after end=$StaleRuntimeAfterPurge"
${If} $StaleRuntimeAfterPurge = 0
DetailPrint "Files of the previous version were removed; continuing the installation."
Return
${EndIf}
; Refresh the names one last time so the abort that follows describes what survived the
; end, and then let it stop the run: the same dialog again would only repeat itself.
Call LogStaleRuntimeDiagnostics
FunctionEnd