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