From 2ea11d45993664c534f95eff7facf30a2a72f835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 26 Oct 2026 07:46:01 +0100 Subject: [PATCH] [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. --- cmake/NSIS.template.in | 1645 +--------------------------------- cmake/nsis/installer-log.nsh | 72 ++ cmake/nsis/process-guard.nsh | 688 ++++++++++++++ cmake/nsis/runtime-purge.nsh | 439 +++++++++ cmake/nsis/user-data.nsh | 492 ++++++++++ 5 files changed, 1713 insertions(+), 1623 deletions(-) create mode 100644 cmake/nsis/installer-log.nsh create mode 100644 cmake/nsis/process-guard.nsh create mode 100644 cmake/nsis/runtime-purge.nsh create mode 100644 cmake/nsis/user-data.nsh diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 923aded9d..7c3d255e8 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -1,3 +1,17 @@ +; Cockatrice NSIS installer. +; +; This file is the CPack template. CPack substitutes the @...@ placeholders in +; this file and in no other, so everything that has no substitution in it lives +; in cmake/nsis/ and is included from below: +; +; installer-log.nsh the CockLog macros and the writers behind them +; 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 +; +; What stays here is the page and section order, the Vars those files share, +; .onInit (it names the package version in the log), AbortInstall (its Sentry +; offer is substituted), componentsPagePre and the registry bookkeeping. !include ..\..\..\NSIS.definitions.nsh Name "@CPACK_PACKAGE_NAME@" BrandingText "@CPACK_PACKAGE_FILE_NAME@" @@ -136,73 +150,8 @@ Page Custom PortableModePageCreate PortableModePageLeave !insertmacro MUI_LANGUAGE English -; Installer log -; Every run appends a step-by-step trace to a file so a failed upgrade on a -; user's machine can be diagnosed after the fact. Kept in $TEMP rather than in -; $INSTDIR because the previous version's uninstaller removes the whole install -; directory part-way through an upgrade. Read it back with: -; notepad %TEMP%\Cockatrice-installer.log -!macro CockLog text - Push "${text}" - Call CockLogWrite -!macroend - -; The body is shared because the uninstaller needs its own copy: un.* functions are -; compiled into a separate binary, and inside one a Call may only name an un. function, -; so CockLog's Call to CockLogWrite cannot be used there. -!macro CockLogWriteBody - Pop $LogMessage - FileOpen $LogHandle "$InstallLogPath" a - ${If} $LogHandle == "" - Return - ${EndIf} - ; FileOpen places the pointer at the start of the file in every mode, so - ; 'a' alone would overwrite the log from the beginning on each line. Seek - ; past the existing content first to actually append. - FileSeek $LogHandle 0 END - FileWrite $LogHandle "$LogMessage$\r$\n" - FileClose $LogHandle -!macroend - -Function CockLogWrite - !insertmacro CockLogWriteBody -FunctionEnd - -; CockLog for un.* code: same lines, prefixed call target. -!macro CockLogUn text - Push "${text}" - Call un.CockLogWrite -!macroend - -Function un.CockLogWrite - !insertmacro CockLogWriteBody -FunctionEnd - -; Logs every file matching $INSTDIR\. 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 +; Installer log helpers (CockLog and friends). +!include "${NSIS_SOURCE_PATH}\cmake\nsis\installer-log.nsh" Function .onInit @@ -509,831 +458,11 @@ ${EndIf} FunctionEnd -; Waits for the previous installation's payload to be gone, so the sections below never -; write into a directory an old uninstaller is still emptying. -; -; ExecWait only waits for as long as the uninstaller runs in place: an NSIS uninstaller -; that did not get a usable _?= copies itself to %TEMP%, starts that copy and exits, so -; ExecWait returns at once while the copy keeps deleting - and it finishes after this -; window has closed, removing the version that was just installed. Waiting for -; cockatrice.exe to disappear covers both cases, and stops with a message instead of -; -; When the payload is still there after the wait, $OldUninstallExit decides: a non-zero -; code means the uninstaller was cancelled or failed, and the install aborts saying so. -; A zero code means it finished but skipped files someone was holding (NSIS Delete does -; that silently), so RemoveOldPayloadLeftovers frees and removes them, and the install -; only aborts - naming the holders - when even that fails. -; installing over a directory that is still being emptied. -Function WaitForOldPayloadToDisappear -${If} $OldInstallDir == "" - Return -${EndIf} -; The registered uninstaller could not be run at all (missing, or ExecWait could not -; start it): nothing is going to empty the directory, so waiting is pointless and -; aborting as "cancelled" was wrong. Remove the previous payload the same way the -; skipped-files recovery below does, and abort - naming why - only if that fails. -${If} $OldUninstallExit == "missing" -${OrIf} $OldUninstallExit == "launch-failed" - ${IfNot} ${FileExists} "$OldInstallDir\*.*" - !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit and $OldInstallDir is already gone" - Return - ${EndIf} - !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit ('$OldUninstallString'); removing $OldInstallDir payload directly" - Call AppendUninstallerLog - Call RemoveOldPayloadLeftovers - ${If} $OldPayloadRemoved != "1" - StrCpy $AbortReason \ - "The previous version in $OldInstallDir could not be removed: its uninstaller ($OldUninstallExe) could not be run ($OldUninstallExit), and removing the files directly failed.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again." - Call RestoreUserDataAfterUninstall - Call AbortInstall - ${EndIf} - !insertmacro CockLog "old uninstall: continuing after removing the payload of an uninstaller that could not run" - Return -${EndIf} -StrCpy $R2 0 -old_uninstall_wait: -IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone -IntOp $R2 $R2 + 1 -${If} $R2 > 60 - !insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s (uninstaller exit=$OldUninstallExit)" - ; The uninstaller's own account of what it skipped or why it stopped lives in its - ; separate log; copy it here so the report sent from AbortInstall carries it. - Call AppendUninstallerLog - ${If} $OldUninstallExit != "0" - ; Cancelled or failed with a non-zero exit code (an uninstaller that could not be - ; started is handled above): the old version was not meant to be removed, so - ; nothing is deleted on its behalf. - StrCpy $AbortReason \ - "The previous version in $OldInstallDir is still there because its uninstaller was cancelled or failed (exit code '$OldUninstallExit').$\r$\n$\r$\nClose anything still using those files and run this installer again." - ; The backup was taken before the uninstaller ran; an abort must not leave the - ; only copy of the user data in %TEMP%, where the next run clears it. - Call RestoreUserDataAfterUninstall - Call AbortInstall - ${EndIf} - ; The uninstaller finished but skipped files - a running instance or a scanner holding - ; them, which NSIS Delete passes over silently. Free them and remove what is left. - Call RemoveOldPayloadLeftovers - ; The removal reports success only when every payload executable is gone and the - ; DLL/Plugins purge came back clean; cockatrice.exe alone would hide a locked - ; oracle.exe or a stale DLL the new payload is about to be written over. - ${If} $OldPayloadRemoved != "1" - StrCpy $AbortReason \ - "The previous version in $OldInstallDir could not be fully removed after its uninstaller ran.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again." - Call RestoreUserDataAfterUninstall - Call AbortInstall - ${EndIf} - ; The payload is gone and ExecWait already waited for the uninstaller; keep the - ; short settle the other paths use, then hand back to the caller's restore. - Sleep 1000 - !insertmacro CockLog "old uninstall: continuing after removing the leftovers the uninstaller skipped" - Return -${EndIf} -Sleep 500 -Goto old_uninstall_wait -old_uninstall_gone: -${If} $R2 > 0 - ; ExecWait returned while the old payload was still there: that uninstaller - ; copied itself to %TEMP%, and the copy is still working through its file list. - ; cockatrice.exe is gone before the rest of it, so let the copy settle before - ; the sections below start writing files it would otherwise remove. - Sleep 1000 - !insertmacro CockLog "old uninstall: ExecWait returned early, settled 1 s after $R2 * 500 ms" -${Else} - !insertmacro CockLog "old uninstall: $OldInstallDir was already clear when ExecWait returned" -${EndIf} -Return -FunctionEnd +; User data backup/restore around the old uninstaller. +!include "${NSIS_SOURCE_PATH}\cmake\nsis\user-data.nsh" -; 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=), on the interactive path componentsPagePre -; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is -; still the default there (directory page comes later). -${If} $OldInstallDir == "" - StrCpy $OldInstallDir $INSTDIR -${EndIf} -; %TEMP% survives across installer runs, so a previous run's safety copy would -; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files -; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B -; from run 2) or restoring stale content after a failed copy. Wipe once per -; installer run - $DataBackupDir is empty until the first backup of this run, -; so the second call in sequence (32-bit then 64-bit uninstall) still merges. -${If} $DataBackupDir == "" - RMDir /r "$TEMP\CockatriceDataBackup" -${EndIf} -; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second -; block points at a different directory, merging would restore the first dir's -; files into the second one, so start over instead. The previous pass already -; restored its own backup; only its leftover %TEMP% copy is discarded. -; StrCmp is case-sensitive while Windows paths are not, so a casing-only -; difference triggers a harmless redundant re-backup of the same content. -${If} $DataBackupDir != "" -${AndIf} $DataBackupForDir != "" -${AndIf} $OldInstallDir != $DataBackupForDir - !insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup" - RMDir /r "$TEMP\CockatriceDataBackup" - StrCpy $DataBackupDir "" - StrCpy $DataBackupDone "" - StrCpy $LooseDecksBackupDone "" - StrCpy $InstallSubdirsBackupDone "" -${EndIf} -${If} ${FileExists} "$OldInstallDir\portable.dat" -${AndIf} ${FileExists} "$OldInstallDir\data" - StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" - StrCpy $DataBackupDone "1" - !insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)" - DetailPrint "Backing up portable user data before removing previous version..." - CreateDirectory "$DataBackupDir\data" - ; Copy every top-level entry except pics (downloaded pictures are large and - ; re-downloadable): copying everything first and deleting pics afterwards - ; would duplicate gigabytes into %TEMP% just to throw them away. - FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*" - ${If} $DataBackupFindHandle != "" - backup_data_loop: - ${If} $DataBackupFindName != "." - ${AndIf} $DataBackupFindName != ".." - ${AndIf} $DataBackupFindName != "pics" - ${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*" - CreateDirectory "$DataBackupDir\data\$DataBackupFindName" - CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\" - ${Else} - CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\" - ${EndIf} - ${EndIf} - ClearErrors - FindNext $DataBackupFindHandle $DataBackupFindName - IfErrors 0 backup_data_loop - FindClose $DataBackupFindHandle - ${EndIf} - ; Keep user-made custom pics (data\pics\CUSTOM). - ${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM" - CreateDirectory "$DataBackupDir\data\pics\CUSTOM" - CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\" - ${EndIf} - DetailPrint "Portable user data backup complete." -${EndIf} -; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a -; blanket *.txt copy would also grab payload-adjacent text files, while the -; deck-specific extensions are unambiguous user content. -${If} ${FileExists} "$OldInstallDir\*.cod" -${OrIf} ${FileExists} "$OldInstallDir\*.dec" -${OrIf} ${FileExists} "$OldInstallDir\*.dek" -${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" - ${If} $DataBackupDir == "" - StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" - ${EndIf} - StrCpy $LooseDecksBackupDone "1" - !insertmacro CockLog "pre-uninstall backup: copying loose deck files" - DetailPrint "Backing up deck files stored in the install directory..." - CreateDirectory "$DataBackupDir\loose_decks" - CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\" - CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\" - CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\" - CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\" -${EndIf} -; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the -; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the -; client's startup backup has never run yet, so stash every top-level directory -; that is not part of the payload. Payload dirs stay in place: they are rewritten -; by the section below, and restoring them could put stale DLLs next to the new -; executables. "data" is skipped once the portable branch above already took it; -; without portable.dat it is user content like any other unknown dir. -FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*" -${If} $DataBackupFindHandle != "" -backup_subdirs_loop: - ${If} $DataBackupFindName != "." - ${AndIf} $DataBackupFindName != ".." - ${AndIf} $DataBackupFindName != "plugins" - ${AndIf} $DataBackupFindName != "Plugins" - ${AndIf} $DataBackupFindName != "translations" - ${AndIf} $DataBackupFindName != "sounds" - ${AndIf} $DataBackupFindName != "themes" - ${If} $DataBackupFindName == "data" - ${AndIf} $DataBackupDone == "1" - Goto backup_subdirs_next - ${EndIf} - ${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" - ${If} $DataBackupDir == "" - StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" - ${EndIf} - StrCpy $InstallSubdirsBackupDone "1" - !insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName" - DetailPrint "Backing up $DataBackupFindName stored in the install directory..." - CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName" - CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\" - ${EndIf} - ${EndIf} -backup_subdirs_next: - ClearErrors - FindNext $DataBackupFindHandle $DataBackupFindName - IfErrors 0 backup_subdirs_loop - FindClose $DataBackupFindHandle -${EndIf} -; Remember which directory the current backup belongs to, so a later pass for a -; different directory starts over instead of merging (see above). -${If} $DataBackupDir != "" - StrCpy $DataBackupForDir $OldInstallDir -${EndIf} -FunctionEnd - -Function RestoreUserDataAfterUninstall -StrCmp $DataBackupDir "" done -${If} $OldInstallDir == "" - StrCpy $OldInstallDir $INSTDIR -${EndIf} -${If} $DataBackupDone == "1" -${AndIf} ${FileExists} "$DataBackupDir\data" - ${If} ${FileExists} "$OldInstallDir\data" - ; Existence-only: a partially-deleted data dir (locked files during the old - ; uninstall) looks the same as a survived one, so the backup is always kept. - ; Merge the backup back over it to repair partial deletes; CopyFiles only - ; adds/overwrites, so a fully-survived dir is unaffected. - !insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it" - CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" - DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data." - ${Else} - !insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data" - CreateDirectory "$OldInstallDir\data" - CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" - DetailPrint "Restored portable user data from pre-uninstall backup." - ${EndIf} -${EndIf} -${If} $LooseDecksBackupDone == "1" -${AndIf} ${FileExists} "$DataBackupDir\loose_decks" - !insertmacro CockLog "post-uninstall restore: restoring loose deck files" - CreateDirectory "$OldInstallDir" - CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\" - CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\" - CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\" - CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\" - DetailPrint "Restored deck files stored in the install directory." -${EndIf} -${If} $InstallSubdirsBackupDone == "1" -${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" - !insertmacro CockLog "post-uninstall restore: restoring install subdirectories" - CreateDirectory "$OldInstallDir" - FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" - ${If} $DataBackupFindHandle != "" - restore_subdirs_loop: - ${If} $DataBackupFindName != "." - ${AndIf} $DataBackupFindName != ".." - ${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" - !insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName" - CreateDirectory "$OldInstallDir\$DataBackupFindName" - CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\" - ${EndIf} - ClearErrors - FindNext $DataBackupFindHandle $DataBackupFindName - IfErrors 0 restore_subdirs_loop - FindClose $DataBackupFindHandle - ${EndIf} - DetailPrint "Restored folders stored in the install directory." -${EndIf} -; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the -; notice a single time per installer run, covering both loose decks and -; restored subdirectories. -${IfNot} ${Silent} -${AndIf} $LooseDeckNoticeShown != "1" - ${If} $LooseDecksBackupDone == "1" - ${OrIf} $InstallSubdirsBackupDone == "1" - StrCpy $LooseDeckNoticeShown "1" - MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again." - ${EndIf} -${EndIf} -done: -FunctionEnd - -; RelocateRestoredDataToInstDir -; -; componentsPagePre backs up and restores around the old uninstaller before the -; directory page, so $OldInstallDir (registry InstallLocation) is authoritative -; there while $INSTDIR is still the default. When the user then picks a different -; target directory, the restored user data would otherwise stay orphaned in the -; old directory while the new install starts empty. Copy it across when they differ -; (a copy, not a move: the source is user data and is deliberately left in place). -; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup -; comment above); with two different old dirs the first one's data stays restored -; in place instead of moving along. -; StrCmp is case-sensitive while Windows paths are not; a casing-only difference -; triggers a harmless redundant copy onto itself. -Function RelocateRestoredDataToInstDir -StrCmp $DataBackupDir "" done -StrCmp $OldInstallDir "" done -StrCmp $OldInstallDir $INSTDIR done -${If} $DataBackupDone == "1" -${AndIf} ${FileExists} "$OldInstallDir\data" - !insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data" - CreateDirectory "$INSTDIR\data" - CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\" - DetailPrint "Copied restored user data to the new install folder." -${EndIf} -${If} $LooseDecksBackupDone == "1" - ${If} ${FileExists} "$OldInstallDir\*.cod" - ${OrIf} ${FileExists} "$OldInstallDir\*.dec" - ${OrIf} ${FileExists} "$OldInstallDir\*.dek" - ${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" - !insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR" - CreateDirectory "$INSTDIR" - CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\" - CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\" - CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\" - CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\" - DetailPrint "Copied restored deck files to the new install folder." - ${EndIf} -${EndIf} -${If} $InstallSubdirsBackupDone == "1" -${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" - FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" - ${If} $DataBackupFindHandle != "" - relocate_subdirs_loop: - ${If} $DataBackupFindName != "." - ${AndIf} $DataBackupFindName != ".." - ${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" - !insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName" - CreateDirectory "$INSTDIR\$DataBackupFindName" - CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\" - DetailPrint "Copied restored folder $DataBackupFindName to the new install folder." - ${EndIf} - ClearErrors - FindNext $DataBackupFindHandle $DataBackupFindName - IfErrors 0 relocate_subdirs_loop - FindClose $DataBackupFindHandle - ${EndIf} -${EndIf} -done: -FunctionEnd - -; --- Running instance handling --- -; Cockatrice, Oracle and Servatrice must not be running while files are -; replaced or deleted. A still-running process holds locks on its .exe and -; Qt runtime DLLs, so a silent upgrade could otherwise end up with a mix of -; old and new Qt DLLs next to the new executable, failing with -; "The procedure entry point X could not be located in the dynamic link -; library ...Qt6Network.dll" on the next start. -; -; Both questions this installer has about running processes - "may I replace -; the payload?" and "what is holding this file I could not delete?" - are -; answered by tools/installer-holder-probe, a small executable built from this -; repository and embedded in the installer and the uninstaller alike. It names -; every process and service that has one of the payload files open or mapped -; (the Restart Manager, RmRegisterResources/RmGetList) and, separately, every -; 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 +; Running instance handling, installer and uninstaller copy. +!include "${NSIS_SOURCE_PATH}\cmake\nsis\process-guard.nsh" ; Aborts the installation, showing $AbortReason to the user. ; @@ -1401,738 +530,8 @@ Function AbortInstall Abort FunctionEnd -; usage: set $GuardDir and $WaitPid, then call this. Asks the one process the installer -; waits on to close and sets $R3 to what it could do: 1 = a window exists and was asked to -; close (or is waiting on a question of its own), 4 = it has no window a user could answer, -; anything else = the probe could not answer. Only 4 lets the wait act on its own; every -; other code keeps the user in the decision, because the installer holds a live handle and -; "the probe did not find it" is not the same answer as "it is gone". -; -; The guard's CloseHolders answers the same question for every holder and leaves a -; windowless instance to the user, which is right there: the prompt names it. The launcher -; is the one process nobody can be asked about - the in-app updater runs the installer -; silent and detached, and a client that has already hidden its window has answered its -; own prompts - so this answer has to say whether there was a window to ask at all. -Function CloseLauncher - Call ExtractHolderProbe - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_LAUNCHER_PID", "$WaitPid")' - ; The probe waits 30 s itself when it posts a close. The timeout here only bounds a - ; probe that never returns; the Restart Manager call the other modes make is not - ; reached in this one. - nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-launcher' - Pop $R3 - Pop $R4 - Push "close-launcher: probe exit=$R3 [$R4]" - Call CockLogWrite - DetailPrint "Closing Cockatrice: $R4" -FunctionEnd - -; Waits for the application that launched this installer to exit, using the /PID= -; it passed. Sets $LauncherExited to 1 when that process is provably gone. -; -; This is the authority for the one question the rest of the installer can only -; approximate: is the process that started us still holding its Qt DLLs mapped? -; A mapped module cannot be deleted, so an answer of "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 - -; 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 - -; 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 +; Stale runtime purge, diagnostics and the holder probe. +!include "${NSIS_SOURCE_PATH}\cmake\nsis\runtime-purge.nsh" Function PortableModePageCreate diff --git a/cmake/nsis/installer-log.nsh b/cmake/nsis/installer-log.nsh new file mode 100644 index 000000000..670968abb --- /dev/null +++ b/cmake/nsis/installer-log.nsh @@ -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\. 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 diff --git a/cmake/nsis/process-guard.nsh b/cmake/nsis/process-guard.nsh new file mode 100644 index 000000000..f470c6a85 --- /dev/null +++ b/cmake/nsis/process-guard.nsh @@ -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 diff --git a/cmake/nsis/runtime-purge.nsh b/cmake/nsis/runtime-purge.nsh new file mode 100644 index 000000000..7a68a678a --- /dev/null +++ b/cmake/nsis/runtime-purge.nsh @@ -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 diff --git a/cmake/nsis/user-data.nsh b/cmake/nsis/user-data.nsh new file mode 100644 index 000000000..f2f1b2048 --- /dev/null +++ b/cmake/nsis/user-data.nsh @@ -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=), 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