This commit is contained in:
BruebachL 2026-11-04 10:30:36 -07:00 • committed by GitHub
commit 850f31fe63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1713 additions and 1623 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
; Installer log helpers: CockLog, CockLogWrite and CockLogListing. Included
; before the first function that logs, because CockLog is a macro and is expanded
; at the point of use. Nothing here returns anything; CockLogWrite uses
; $LogMessage/$LogHandle, CockLogListing the $Log* match variables.
; 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

View file

@ -0,0 +1,688 @@
; Running instance handling: make sure no installed application still holds the
; files that are about to be deleted or replaced, closing or force-stopping it
; where the caller allows that, and prompting the user where it does not.
;
; The probe routines exist twice, once plain and once with the un prefix. That is
; forced by NSIS: the uninstaller is a separate program compiled into the same
; installer, and it cannot call the installer's functions. Keep the two copies in
; step. The launcher wait is installer-only: the uninstaller is never given a
; /PID= to wait on.
;
; Register contract:
; $GuardDir in: directory a matched process has to run from
; $GuardExit out: RunHolderProbe - 0 when nothing holds the payload, 1
; when its line names what does, anything else = no answer
; $GuardOutput out: RunHolderProbe - its one-line answer
; $GuardCloseRounds/$GuardLastHolders/$GuardRecheckCount
; internal: the guard's automatic close and its rechecks
; $GuardPrompt internal: the question CockEndPrompt/CockEndRecheckPrompt build
; $WaitPid in: WaitForLauncherToExit - the launcher's /PID=
; $WaitSlice/$WaitCanEnd/$WaitWindowless
; internal: the launcher wait's slice and what it may do
; $LauncherExited out: WaitForLauncherToExit - 1 when the /PID= process was
; seen to exit, 0 when no usable /PID= was given
; $AbortReason out: set before AbortInstall on every abort path
;
; AbortInstall itself stays in the template: its Sentry offer is substituted by
; CPack, which only configures that one file.
; --- 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
; payload program whose image lies in the directory being replaced rather than
; in a folder below it.
;
; The second lookup is not redundant. Restart Manager answers "who has this
; file open", and a running instance does not have to have one open: its own
; image and the modules it loaded are mapped into it rather than opened by it,
; and a mapped image is not reliably reported. That is the state the failed
; updates this probe exists for were in - every DLL still on disk, an instance
; running from the directory, and "nothing is holding them any more" as the
; answer. A guard built on file handles alone therefore misses exactly the
; instance it exists to find; it would pass, the purge would fail, and the
; install would end in a warning with no offer to close and try again.
;
; Why not enumerate processes from this script: the NSIS stub is a 32-bit
; process, so a bare "powershell" started from it is the 32-bit Windows
; PowerShell, whose Get-Process cannot read the path of a 64-bit process. A
; guard built on that misses exactly the running instance it exists to find.
; The probe opens each process with PROCESS_QUERY_LIMITED_INFORMATION and calls
; QueryFullProcessImageNameW, which has no bitness mismatch, and Restart
; Manager is a system API rather than a view of this process's architecture.
;
; The files that count are the payload the purge removes and the installer
; replaces: the three executables, the top-level DLLs, qt.conf and everything
; below Plugins. A document, a log or a screenshot the user happens to have
; open in the install directory is not part of it and cannot block an upgrade;
; the image lookup matches on the directory a process runs from, so it does not
; widen that set.
;
; Nothing is force-stopped without the user asking for it: Cockatrice may be holding
; an unsaved deck or showing a "really leave this game?" confirmation, Oracle writes
; the card database in place and can be killed mid-write, and Servatrice may be serving
; a game. A close is a request, made automatically because that is what the user started
; the installer to do: the probe first asks every instance over its local socket, which
; reaches a hidden window and an instance sitting on its own confirmation, and falls back
; to WM_CLOSE for an older client. The automatic close is retried while the set of holders
; changes, so one instance exiting does not leave the next one unasked. When a close is
; still not answered the user is told which program is in the way and is offered to force
; close all three, check again after closing them by hand, or abort - the last resort, with
; what it costs spelled out in the prompt.
; One sentence for every prompt whose Yes ends the whole set, so that answer reads the same
; wherever the user meets it: this guard before anything is replaced, the uninstaller's, and
; the recovery prompt after a failed purge.
!define END_COST "An unsaved deck, a running game or an unwritten setting is lost, Servatrice drops its players, and an Oracle that is mid-write can damage the card database."
; What the user does instead when they say no: the prompt's other answer stops the run, so they
; are told how to get past the same wall next time. Also used by the abort dialogs, which is
; where the answer to "why did it stop" ends up.
!define AFTER_CLOSING_GUIDANCE "Answer the confirmation Cockatrice may be showing - open it from the notification area if it is hidden there - and use Task Manager for a program that would not end."
; The escape hatch for a user who would rather not force-close anything: the same release,
; downloaded and run by hand once Cockatrice is closed. Every abort dialog shows this and
; offers to open the page, so an update that cannot free the files still leaves the user a
; way forward that does not end in Task Manager.
!define LATEST_RELEASE_URL "https://github.com/Cockatrice/Cockatrice/releases/latest"
!define MANUAL_UPDATE_GUIDANCE "To update safely instead, close Cockatrice and download the latest installer from:$\r$\n${LATEST_RELEASE_URL}$\r$\nThen run it manually."
; usage: set the variable named by the second argument to the probe's listing, then call
; this. Sets $GuardPrompt to the question the guard and the recovery path ask.
;
; One macro rather than three copies, because the wording is a promise: Yes ends the
; programs the payload installs - which is exactly the set EndHolders acts on - and anything
; else the probe named has to be closed by hand, so a listing that includes a scanner or a
; service hosted elsewhere does not come with an answer that cannot do what it says.
;
; Two answers, one of each: the automatic close already ran when this is asked, so Yes is the
; only thing left that can free the files and No stops the run. The guard itself uses
; CockEndRecheckPrompt instead, whose No checks again; this one stays for the recovery path,
; where a retry cannot change anything because the purge was already retried.
;
; The assembled string has to fit the characters one NSIS string holds, which is what
; bounds HOLDER_LINE_CHAR_MAX in tools\installer-holder-probe\holder_line.h: with the
; longest listing that can arrive it stays inside that budget instead of being cut off at
; the tail, where the cancel line is.
;
; @param header the sentence that introduces the listing, with its trailing colon
; @param listing the variable holding the probe's line, $GuardOutput or $HolderOutput
; @param cancelWord what No gives up, "installation" or "uninstallation"
!macro CockEndPrompt header listing cancelWord
StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}"
StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}"
!macroend
; usage: like CockEndPrompt, for a MB_YESNOCANCEL question whose No checks again.
;
; The automatic close has already run, so the three answers are what is left: force close
; the programs the payload installs (Yes), close them by hand and check again (No), or stop
; the run (Cancel). No used to be the only alternative to ending them, which meant a user who
; would rather close the instance themselves had to abandon the update and start the
; installer again. The listing the question is asked with is refreshed on every pass, so the
; question changes as programs go away instead of repeating itself word for word.
;
; The manual-update guidance lives in the abort dialog, which is where a Cancel ends up, not
; in this prompt. With the longest listing HOLDER_LINE_CHAR_MAX allows, the assembled string
; stays under 900 characters, inside the 1024 one NSIS string holds, so the Cancel line is
; never the part that gets cut off.
;
; @param header the sentence that introduces the listing, with its trailing colon
; @param listing the variable holding the probe's line, $GuardOutput
; @param cancelWord what Cancel gives up, "installation" or "uninstallation"
!macro CockEndRecheckPrompt header listing cancelWord
StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}"
StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to close them yourself, then check again - answer the confirmation Cockatrice may be showing, and open it from the notification area if it is hidden there.$\r$\n$\r$\nChoose Cancel to abort the ${cancelWord}."
!macroend
; Emitted once for the installer and once for the uninstaller: the uninstaller
; cannot call installer functions, and its own copy of the probe keeps a running
; 64-bit instance from being missed there too.
!macro CockHolderProbeFunctions prefix tag
Function ${prefix}ExtractHolderProbe
InitPluginsDir
IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_probe_ready_${tag}
File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe"
ck_probe_ready_${tag}:
FunctionEnd
; usage: set $GuardDir to the directory to check, then call this. Sets
; $GuardExit to the probe's exit code and $GuardOutput to its one-line answer.
; 0 = nothing holds the payload and no payload program runs from the directory, 1 = the
; line names what does, anything else = the probe could not answer (2 = no
; directory was given, 3 = no payload program was named but one refuses to be replaced, or
; the Restart Manager call failed and no payload program runs from the directory either,
; "timeout" = it did not return in time).
Function ${prefix}RunHolderProbe
Call ${prefix}ExtractHolderProbe
; The directory travels in an environment variable, never on the command
; line: a path with spaces, quotes or a trailing backslash then does not
; have to survive NSIS quoting.
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
; /TIMEOUT because this can run before the first page: a Restart Manager
; that does not answer must not leave the installer with no window and no
; way out.
nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"'
Pop $GuardExit
Pop $GuardOutput
FunctionEnd
; usage: set $GuardDir to the directory being replaced, then call this. Asks the running
; Cockatrice to close, and waits for it to exit.
;
; The set is what the probe names: the processes holding a file of the version being
; replaced, plus the payload programs running from the directory itself. The probe
; closes that set by pid, which is why nothing here has to match an executable path. Only
; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no
; window to ask, so both are left alone here and only ended by the explicit answer.
;
; Each instance is asked to close over the local socket it listens on, which is the same
; request as clicking its close button and reaches one whose window is hidden in the
; notification area or is sitting on its own confirmation. A client from before that socket
; is asked with WM_CLOSE on its own window instead, and a visible window is brought forward
; first. Either way the close is answered, not obeyed - Cockatrice asks before it quits while
; a card database update is running, a game is open or a deck is unsaved - and that question
; is only useful if it is in front of the installer instead of behind it.
;
; The probe waits up to 30 s for the processes it asked to exit, so the check that follows
; measures a settled directory rather than sleeping for a fixed time. Its line goes into the
; log as it stands: it names every holder, including the ones left to the user, so a close
; that did nothing can be told apart from one that was never sent.
;
; Never a force-close here: the application decides whether to quit. Ending is a separate
; answer the guard prompt offers when a close did not free the files, spelled out with what
; it costs - see EndHolders - and it ends every program the payload installs.
Function ${prefix}CloseHolders
Call ${prefix}ExtractHolderProbe
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
; 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 asks one question with one answer that can free the
; payload. The automatic close is the same request clicking the window's close button makes,
; so the common case - the client was still shutting down - never turns into a question. The
; prompt is what remains when the close cannot reach everything: Oracle writes the card
; database in place, Servatrice has no window, and a client that is sitting on its own
; confirmation needs an answer nobody can give it from here. It offers to end the programs
; the payload installs - and only those, which the text says - with what that costs (Yes),
; and its other answer stops the install (No) instead of repeating the same dialog. What
; survives an end is reported in the abort, never asked about again - ending is the last
; resort, see EndHolders.
;
; An error is always shown, in every mode. A /S install without /R has nobody at
; the keyboard, but this installer does not promise unattended runs: when an
; error needs a decision, asking for one is better than silently leaving a
; half-updated install behind. The happy path has no dialog, so a /S reinstall
; that has nothing to report stays silent.
Function GuardAppsNotRunning
!insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)"
StrCpy $GuardCloseRounds 0
StrCpy $GuardRecheckCount 0
StrCpy $GuardLastHolders ""
guard_check:
Call RunHolderProbe
${If} $GuardExit == "0"
!insertmacro CockLog "guard: nothing is holding the payload"
DetailPrint "No running Cockatrice, Oracle or Servatrice instance was found."
Return
${EndIf}
${If} $GuardExit == "1"
!insertmacro CockLog "guard: still in use by $GuardOutput"
; Close it for the user first. The probe asks every instance over its local socket
; and falls back to WM_CLOSE for an older client, so this is the same request as
; clicking the window's close button: the application still answers its own "really
; quit?" prompt, so nothing is lost. A second round is spent only when the listing
; changed, which means something exited and the rest is worth asking again; repeating
; the same request against the same processes cannot change anything.
${If} $GuardCloseRounds < 2
${AndIf} $GuardOutput != $GuardLastHolders
StrCpy $GuardLastHolders $GuardOutput
IntOp $GuardCloseRounds $GuardCloseRounds + 1
!insertmacro CockLog "guard: asking the payload programs to close (round $GuardCloseRounds)"
Call CloseHolders
Goto guard_check
${EndIf}
DetailPrint "The install directory is in use by: $GuardOutput"
; The close ran and did not free the files. Force-closing them is what the installer
; can do (Yes); closing them by hand is what the user can do (No checks again, with
; the fresh listing); Cancel stops the run and the abort dialog offers the same
; update from the official download page. Without the middle answer a user who did
; not want anything killed had no way forward but to abandon the update.
guard_ask:
!insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation"
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end IDNO guard_recheck
Goto guard_cancel
guard_recheck:
IntOp $GuardRecheckCount $GuardRecheckCount + 1
!insertmacro CockLog "guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)"
; A fresh set gets a fresh automatic close, exactly as if the guard had just started:
; what the user closed by hand may have been one instance of several.
StrCpy $GuardCloseRounds 0
StrCpy $GuardLastHolders ""
Goto guard_check
guard_cancel:
!insertmacro CockLog "guard: cancelled by user after $GuardRecheckCount recheck(s)"
StrCpy $AbortReason \
"The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again."
; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken;
; restoring here keeps this abort from stranding the only copy in %TEMP%.
Call RestoreUserDataAfterUninstall
Call AbortInstall
; Only reached through Yes: AbortInstall does not return.
guard_end:
!insertmacro CockLog "guard: ending the payload programs on request, then checking again"
Call EndHolders
; Measure once and report, rather than asking the same question again. Everything the
; payload installs was ended, so what survived is a program this installer may not
; open or does not own - another dialog offering to end it again would only repeat
; itself.
Call RunHolderProbe
${If} $GuardExit == "0"
!insertmacro CockLog "guard: nothing is holding the payload"
Return
${EndIf}
!insertmacro CockLog "guard: still in use after ending them ($GuardExit): $GuardOutput"
${If} $GuardExit == "1"
StrCpy $AbortReason \
"The installation was cancelled because these programs still hold files of the previous version and could not be ended:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again."
${Else}
StrCpy $AbortReason \
"The installation was cancelled because, after ending Cockatrice, Oracle and Servatrice, the installer could not check whether they still hold files of the previous version.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them and run the installer again."
${EndIf}
Call RestoreUserDataAfterUninstall
Call AbortInstall
${EndIf}
; Not an answer at all: the probe was not configured, the Restart Manager
; call failed and no payload program runs from the directory, a payload
; program refuses to be replaced but no lookup could name it (it may run
; under another account), or the probe timed out. Carrying on from that is a
; decision, so it is asked for rather than assumed - the stale-runtime check
; after the purge still stands between a failed guard and a mismatched
; install, but it only fires after files have been replaced, and on an
; upgrade the launcher asked for nobody is watching the installer's exit
; code. Retry asks the probe again (a Restart Manager that was busy answers
; the second time, and a program the user has closed frees its file), Ignore
; installs anyway, Abort cancels instead of writing a payload next to files
; the guard could not rule out.
!insertmacro CockLog "guard: the holder probe could not answer ($GuardExit): $GuardOutput"
DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput"
MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
"This installer could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \
IDRETRY guard_check IDABORT guard_unknown_abort
!insertmacro CockLog "guard: continuing without an answer from the probe"
Return
guard_unknown_abort:
StrCpy $AbortReason \
"The installation was cancelled because it could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again."
; Same reason as the cancel path above: this abort can be reached from the
; leftover-removal guard, after the backup was taken.
Call RestoreUserDataAfterUninstall
Call AbortInstall
FunctionEnd
Function EnsureAppsNotRunning
; guards the install directory
StrCpy $GuardDir $INSTDIR
Call GuardAppsNotRunning
FunctionEnd
; usage: set $GuardDir and $WaitPid, then call this. Asks the one process the installer
; waits on to close and sets $R3 to what it could do: 1 = a window exists and was asked to
; close (or is waiting on a question of its own), 4 = it has no window a user could answer,
; anything else = the probe could not answer. Only 4 lets the wait act on its own; every
; other code keeps the user in the decision, because the installer holds a live handle and
; "the probe did not find it" is not the same answer as "it is gone".
;
; The guard's CloseHolders answers the same question for every holder and leaves a
; windowless instance to the user, which is right there: the prompt names it. The launcher
; is the one process nobody can be asked about - the in-app updater runs the installer
; silent and detached, and a client that has already hidden its window has answered its
; own prompts - so this answer has to say whether there was a window to ask at all.
Function CloseLauncher
Call ExtractHolderProbe
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_LAUNCHER_PID", "$WaitPid")'
; The probe waits 30 s itself when it posts a close. The timeout here only bounds a
; probe that never returns; the Restart Manager call the other modes make is not
; reached in this one.
nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-launcher'
Pop $R3
Pop $R4
Push "close-launcher: probe exit=$R3 [$R4]"
Call CockLogWrite
DetailPrint "Closing Cockatrice: $R4"
FunctionEnd
; Waits for the application that launched this installer to exit, using the /PID=
; it passed. Sets $LauncherExited to 1 when that process is provably gone.
;
; This is the authority for the one question the rest of the installer can only
; approximate: is the process that started us still holding its Qt DLLs mapped?
; A mapped module cannot be deleted, so an answer of "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.
;
; A launcher that survives the first grace is asked to close by the probe, and the
; probe also answers whether there was a window a close could reach. A client hides
; its window the moment its own close is accepted, so a launcher without one has
; already answered its own prompts and is stuck in shutdown: no dialog can reach it
; there, and the wait ends it after one more grace instead of asking the user a
; question whose "keep waiting" answer can never change anything.
Function WaitForLauncherToExit
StrCpy $LauncherExited 0
StrCpy $WaitWindowless 0
!insertmacro CockLog "wait-launcher: pid='$WaitPid'"
${If} $WaitPid == ""
!insertmacro CockLog "wait-launcher: no pid given, falling back to name check"
Return
${EndIf}
; 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."
; Ask the launcher itself before asking the user. A client that has already hidden
; its window has answered its own prompts and is stuck in shutdown: there is nothing
; left a dialog could reach, and the "ask it to close again" answer the dialog used
; to offer could only repeat itself until the user cancelled.
StrCpy $GuardDir $INSTDIR
Call CloseLauncher
; $R3 = what the probe could do (see CloseLauncher), $R4 = its line, already logged.
; A process that answered the close during the probe's own wait is gone by now.
System::Call 'kernel32::WaitForSingleObject(p R0, i 0) i.R1'
${If} $R1 == 0
System::Call 'kernel32::CloseHandle(p R0)'
StrCpy $R0 0
Goto wait_launcher_open
${EndIf}
${If} $R3 == "4"
; Nothing a user could answer: the window is gone, so the launcher is past its
; own prompts and stuck in shutdown. It cannot be asked, and it is not going to
; ask the user anything either, so the dialog below would be unanswerable.
${If} $WaitCanEnd == 1
${If} $WaitWindowless == 0
; Hiding the window is what the client does when its close is accepted,
; so what remains is the shutdown it already started: one full grace for
; that, without a question nobody could act on.
StrCpy $WaitWindowless 1
StrCpy $WaitSlice 60000
!insertmacro CockLog "wait-launcher: no window to ask, giving it one more minute"
System::Call 'kernel32::CloseHandle(p R0)'
StrCpy $R0 0
Goto wait_launcher_open
${EndIf}
${If} $WaitWindowless == 1
; It did not use that minute either. Ending it is what the dialog's Yes
; answer would have done, and there is no question left to put to the
; user: the instance lost its window before this round started.
StrCpy $WaitWindowless 2
!insertmacro CockLog "wait-launcher: still no window to ask; ending the launcher"
DetailPrint "The application that started this installer could not be asked to close; ending it."
System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R2'
!insertmacro CockLog "wait-launcher: ending the launcher returned $R2"
System::Call 'kernel32::CloseHandle(p R0)'
StrCpy $R0 0
Goto wait_launcher_open
${EndIf}
${EndIf}
${EndIf}
; A window exists, or the launcher cannot be ended from here: the three answers
; below. "Carry on anyway" is not one of them - a live process has its image and its
; Qt DLLs mapped, so writing the payload over them is the broken install this wait
; exists to prevent. Yes ends the launcher, Cancel cancels, and No keeps waiting:
; the close and the bring-forward just happened above.
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \
"Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or an unwritten setting is lost.$\r$\n$\r$\nChoose No to keep waiting, or Cancel to cancel the installation." \
IDYES wait_launcher_end IDNO wait_launcher_retry
!insertmacro CockLog "wait-launcher: cancelled by user"
StrCpy $AbortReason \
"The application that started this update has not exited yet, so the installation was cancelled rather than replace files it is still using.$\r$\n$\r$\nPlease close Cockatrice and run the installer again."
Call AbortInstall
; Only reached through Yes or No: AbortInstall does not return.
wait_launcher_end:
; End it through the handle waited on above, and through nothing else: no pid is
; looked up again, so a process that inherited the number in the meantime - or a
; /PID= that was not the launcher to begin with - cannot be hit by this.
!insertmacro CockLog "wait-launcher: ending the launcher on request"
${If} $WaitCanEnd == 0
!insertmacro CockLog "wait-launcher: the launcher could not be opened to end it, asking it to close instead"
Goto wait_launcher_retry
${EndIf}
System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R3'
System::Call 'kernel32::CloseHandle(p R0)'
StrCpy $R0 0
!insertmacro CockLog "wait-launcher: ending the launcher returned $R3"
Goto wait_launcher_open
wait_launcher_retry:
System::Call 'kernel32::CloseHandle(p R0)'
StrCpy $R0 0
; The question has been asked and answered once; from here the wait is the full
; grace again, so a slow flush or a confirmation being answered does not turn
; into a stream of prompts. The close itself and the bring-forward already
; happened just before the dialog, so this only waits. The windowless grace starts
; over as well: "keep waiting" must not be answered by ending the process a minute
; later without asking again.
StrCpy $WaitWindowless 0
StrCpy $WaitSlice 60000
Goto wait_launcher_open
${EndIf}
System::Call 'kernel32::CloseHandle(p R0)'
StrCpy $R0 0
!insertmacro CockLog "wait-launcher: launcher exited"
DetailPrint "The application that started this installer has exited."
StrCpy $LauncherExited 1
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
; 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 $GuardCloseRounds 0
StrCpy $GuardRecheckCount 0
StrCpy $GuardLastHolders ""
un_guard_check:
!insertmacro CockLogUn "uninst-guard: checking $GuardDir"
Call un.RunHolderProbe
${If} $GuardExit == "0"
!insertmacro CockLogUn "uninst-guard: nothing is holding the payload"
Return
${EndIf}
${If} $GuardExit == "1"
!insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput"
; Same convenience as in the installer: ask every instance to close, repeat only
; while the set changed, then report what the close could not reach.
${If} $GuardCloseRounds < 2
${AndIf} $GuardOutput != $GuardLastHolders
StrCpy $GuardLastHolders $GuardOutput
IntOp $GuardCloseRounds $GuardCloseRounds + 1
!insertmacro CockLogUn "uninst-guard: asking the payload programs to close (round $GuardCloseRounds)"
Call un.CloseHolders
Goto un_guard_check
${EndIf}
DetailPrint "The install directory is in use by: $GuardOutput"
; Same question and the same three answers as the installer's guard: force close,
; close by hand and check again, or abort. CockEndRecheckPrompt words it, so the two
; copies promise the same thing.
!insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation"
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end IDNO un_guard_recheck
!insertmacro CockLogUn "uninst-guard: cancelled by user after $GuardRecheckCount recheck(s)"
; The installer's Cancel lands in AbortInstall, which explains what to do next. The
; uninstaller has no such dialog, so say the same thing here: a bare Abort would
; close the window and leave the user with no reason and no next step.
MessageBox MB_OK|MB_ICONEXCLAMATION \
"The uninstallation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the installation.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the uninstaller again."
SetErrorLevel 1
Abort
un_guard_recheck:
IntOp $GuardRecheckCount $GuardRecheckCount + 1
!insertmacro CockLogUn "uninst-guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)"
StrCpy $GuardCloseRounds 0
StrCpy $GuardLastHolders ""
Goto un_guard_check
; Only reached through Yes: Abort does not return.
un_guard_end:
!insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again"
Call un.EndHolders
; One measurement and a stop, not another question: what survived a terminate is a
; program this uninstaller may not open or does not own, and ending it again cannot
; change that.
Call un.RunHolderProbe
${If} $GuardExit == "0"
!insertmacro CockLogUn "uninst-guard: nothing is holding the payload"
Return
${EndIf}
!insertmacro CockLogUn "uninst-guard: still in use after ending them ($GuardExit): $GuardOutput"
SetErrorLevel 1
Abort
${EndIf}
; Same reasoning as in the installer: without an answer from the probe we cannot tell
; whether a running instance still holds file locks, and refusing outright would leave
; the user with an uninstaller that never completes. Same three answers too, so an
; uninstall that hits a Restart Manager that was busy, or a payload program the user has
; not closed yet, can simply be retried.
!insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput"
DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput"
MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
"This uninstaller could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \
IDRETRY un_guard_check IDABORT un_guard_unknown_abort
!insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe"
Return
un_guard_unknown_abort:
!insertmacro CockLogUn "uninst-guard: cancelled after the probe could not answer"
SetErrorLevel 1
Abort
FunctionEnd

View file

@ -0,0 +1,439 @@
; 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

492
cmake/nsis/user-data.nsh Normal file
View file

@ -0,0 +1,492 @@
; User data backup and restore around the previous version's uninstaller.
;
; Kept apart from the upgrade orchestration in the template because all of it is
; about the previous version's uninstaller: it waits for that uninstaller's
; payload to disappear, removes what it left behind and appends its log, and it
; copies the user's data out of $OldInstallDir before the uninstaller runs and
; puts it back afterwards. The reasoning for the shared %TEMP% backup directory
; and for the copy-not-move relocation is in the comment above each function.
;
; Registers: $OldUninstallExit/$OldUninstallExe/$OldPayloadRemoved for the
; leftover handling, $DataBackupDir, $DataBackupDone, $DataBackupForDir and the
; $OldInstallDir/$INSTDIR pair for the backup. Removing leftovers aborts the
; install when it cannot free the files; the backup and restore never do.
; Waits for the previous installation's payload to be gone, so the sections below never
; write into a directory an old uninstaller is still emptying.
;
; ExecWait only waits for as long as the uninstaller runs in place: an NSIS uninstaller
; that did not get a usable _?= copies itself to %TEMP%, starts that copy and exits, so
; ExecWait returns at once while the copy keeps deleting - and it finishes after this
; window has closed, removing the version that was just installed. Waiting for
; cockatrice.exe to disappear covers both cases, and stops with a message instead of
;
; When the payload is still there after the wait, $OldUninstallExit decides: a non-zero
; code means the uninstaller was cancelled or failed, and the install aborts saying so.
; A zero code means it finished but skipped files someone was holding (NSIS Delete does
; that silently), so RemoveOldPayloadLeftovers frees and removes them, and the install
; only aborts - naming the holders - when even that fails.
; installing over a directory that is still being emptied.
Function WaitForOldPayloadToDisappear
${If} $OldInstallDir == ""
Return
${EndIf}
; The registered uninstaller could not be run at all (missing, or ExecWait could not
; start it): nothing is going to empty the directory, so waiting is pointless and
; aborting as "cancelled" was wrong. Remove the previous payload the same way the
; skipped-files recovery below does, and abort - naming why - only if that fails.
${If} $OldUninstallExit == "missing"
${OrIf} $OldUninstallExit == "launch-failed"
${IfNot} ${FileExists} "$OldInstallDir\*.*"
!insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit and $OldInstallDir is already gone"
Return
${EndIf}
!insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit ('$OldUninstallString'); removing $OldInstallDir payload directly"
Call AppendUninstallerLog
Call RemoveOldPayloadLeftovers
${If} $OldPayloadRemoved != "1"
StrCpy $AbortReason \
"The previous version in $OldInstallDir could not be removed: its uninstaller ($OldUninstallExe) could not be run ($OldUninstallExit), and removing the files directly failed.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again."
Call RestoreUserDataAfterUninstall
Call AbortInstall
${EndIf}
!insertmacro CockLog "old uninstall: continuing after removing the payload of an uninstaller that could not run"
Return
${EndIf}
StrCpy $R2 0
old_uninstall_wait:
IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone
IntOp $R2 $R2 + 1
${If} $R2 > 60
!insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s (uninstaller exit=$OldUninstallExit)"
; The uninstaller's own account of what it skipped or why it stopped lives in its
; separate log; copy it here so the report sent from AbortInstall carries it.
Call AppendUninstallerLog
${If} $OldUninstallExit != "0"
; Cancelled or failed with a non-zero exit code (an uninstaller that could not be
; started is handled above): the old version was not meant to be removed, so
; nothing is deleted on its behalf.
StrCpy $AbortReason \
"The previous version in $OldInstallDir is still there because its uninstaller was cancelled or failed (exit code '$OldUninstallExit').$\r$\n$\r$\nClose anything still using those files and run this installer again."
; The backup was taken before the uninstaller ran; an abort must not leave the
; only copy of the user data in %TEMP%, where the next run clears it.
Call RestoreUserDataAfterUninstall
Call AbortInstall
${EndIf}
; The uninstaller finished but skipped files - a running instance or a scanner holding
; them, which NSIS Delete passes over silently. Free them and remove what is left.
Call RemoveOldPayloadLeftovers
; The removal reports success only when every payload executable is gone and the
; DLL/Plugins purge came back clean; cockatrice.exe alone would hide a locked
; oracle.exe or a stale DLL the new payload is about to be written over.
${If} $OldPayloadRemoved != "1"
StrCpy $AbortReason \
"The previous version in $OldInstallDir could not be fully removed after its uninstaller ran.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again."
Call RestoreUserDataAfterUninstall
Call AbortInstall
${EndIf}
; The payload is gone and ExecWait already waited for the uninstaller; keep the
; short settle the other paths use, then hand back to the caller's restore.
Sleep 1000
!insertmacro CockLog "old uninstall: continuing after removing the leftovers the uninstaller skipped"
Return
${EndIf}
Sleep 500
Goto old_uninstall_wait
old_uninstall_gone:
${If} $R2 > 0
; ExecWait returned while the old payload was still there: that uninstaller
; copied itself to %TEMP%, and the copy is still working through its file list.
; cockatrice.exe is gone before the rest of it, so let the copy settle before
; the sections below start writing files it would otherwise remove.
Sleep 1000
!insertmacro CockLog "old uninstall: ExecWait returned early, settled 1 s after $R2 * 500 ms"
${Else}
!insertmacro CockLog "old uninstall: $OldInstallDir was already clear when ExecWait returned"
${EndIf}
Return
FunctionEnd
; Called when the old uninstaller exited successfully but left its payload behind, or
; could not be run at all (missing or not startable) and the payload is still there.
; Frees the files the same way the guard does (close, then offer to end), deletes what
; the uninstaller skipped, and on failure records which files survived and who holds
; them in $HolderOutput, for the abort message. Sets $OldPayloadRemoved to 1 only when
; every payload executable is gone and the DLL/Plugins purge is clean.
Function RemoveOldPayloadLeftovers
!insertmacro CockLog "old uninstall: removing leftovers of $OldInstallDir"
StrCpy $OldPayloadRemoved 0
StrCpy $HolderOutput "nothing could be named"
StrCpy $GuardDir $OldInstallDir
Call GuardAppsNotRunning
; The purge helpers work on $INSTDIR; point them at the old directory for the
; duration (on /R the two are the same anyway).
Push $INSTDIR
StrCpy $INSTDIR $OldInstallDir
StrCpy $R2 0
rop_loop:
SetFileAttributes "$INSTDIR\cockatrice.exe" NORMAL
SetFileAttributes "$INSTDIR\oracle.exe" NORMAL
SetFileAttributes "$INSTDIR\servatrice.exe" NORMAL
Delete "$INSTDIR\cockatrice.exe"
Delete "$INSTDIR\oracle.exe"
Delete "$INSTDIR\servatrice.exe"
Delete "$INSTDIR\qt.conf"
Call RetryDllPurge
; $R0 is RetryDllPurge's verdict (1 = a DLL or the Plugins directory survived).
; Every payload executable has to be gone as well: a locked oracle.exe or
; servatrice.exe is just as fatal to the install that follows as cockatrice.exe.
IfFileExists "$INSTDIR\cockatrice.exe" rop_retry
IfFileExists "$INSTDIR\oracle.exe" rop_retry
IfFileExists "$INSTDIR\servatrice.exe" rop_retry
${If} $R0 == 0
Goto rop_done
${EndIf}
rop_retry:
IntOp $R2 $R2 + 1
${If} $R2 < 3
!insertmacro CockLog "old uninstall: leftovers of $OldInstallDir survived removal attempt $R2, retrying in 2 s"
Sleep 2000
Goto rop_loop
${EndIf}
!insertmacro CockLog "old uninstall: leftovers of $OldInstallDir still present after 3 removal attempts"
!insertmacro CockLogListing "old-leftovers" "*.*"
; Per-file Windows codes for the DLLs plus the holder probe's line ($HolderOutput).
Call LogStaleRuntimeDiagnostics
Pop $INSTDIR
Return
rop_done:
StrCpy $OldPayloadRemoved 1
; Only now that the recovery has succeeded: deleting the old uninstaller on a
; failed attempt would leave a directory whose UninstallString points at nothing,
; so no later run could remove it. WriteUninstaller recreates it on success anyway.
SetFileAttributes "$INSTDIR\uninstall.exe" NORMAL
Delete "$INSTDIR\uninstall.exe"
!insertmacro CockLog "old uninstall: leftovers of $OldInstallDir removed"
!insertmacro CockLogListing "old-leftovers-after-removal" "*.*"
Pop $INSTDIR
FunctionEnd
; Copies the old uninstaller's log ($TEMP\Cockatrice-uninstaller.log) into this
; installer's log, so the report AbortInstall sends shows what the uninstaller did.
; Uninstallers older than this log simply have no file; that is logged too.
Function AppendUninstallerLog
ClearErrors
FileOpen $UninstLogHandle "$TEMP\Cockatrice-uninstaller.log" r
${If} ${Errors}
${OrIf} $UninstLogHandle == ""
!insertmacro CockLog "uninstaller log: not found at $TEMP\Cockatrice-uninstaller.log"
Return
${EndIf}
!insertmacro CockLog "uninstaller log begin ----------------------------------------"
aul_loop:
ClearErrors
FileRead $UninstLogHandle $UninstLogLine
IfErrors aul_done
; Strip the line break FileRead keeps, so CockLog does not double it.
aul_trim:
StrCpy $R9 $UninstLogLine 1 -1
${If} $R9 == "$\n"
${OrIf} $R9 == "$\r"
StrCpy $UninstLogLine $UninstLogLine -1
Goto aul_trim
${EndIf}
!insertmacro CockLog "uninstaller: $UninstLogLine"
Goto aul_loop
aul_done:
FileClose $UninstLogHandle
!insertmacro CockLog "uninstaller log end ------------------------------------------"
FunctionEnd
; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall
;
; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR".
; 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