diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index b3cbcece8..f3f172693 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -131,6 +131,13 @@ ${EndIf} ; Now that $PortableMode reflects reality, commit InstDir into the correct slot Call SetModeDestinationFromInstdir +; Make sure no application instance is still running (and holding file locks) +; before the previous version is uninstalled or new files are installed. +; On a silent update (/R /S) running processes are asked to close gracefully +; and waited for, then force-closed only on timeout. On interactive installs +; the user is prompted to close them instead. +Call EnsureAppsNotRunning + ${If} $ReinstallMode = 1 ${AndIf} $PortableMode = 0 Call AutoUninstallIfNeeded @@ -144,6 +151,9 @@ ${If} ${NSIS_IS_64_BIT} == 1 SetRegView 64 ${EndIf} +; Ensure no application instance is still running before removing files. +Call un.EnsureAppsNotRunning + FunctionEnd Function RequireAdmin @@ -199,6 +209,170 @@ ${EndIf} 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. +; +; Processes are matched by image name AND by their executable path living +; under $INSTDIR, so unrelated processes that merely share an image name +; (e.g. the Oracle DB instance "oracle.exe") are never touched. + +; usage: set $R2 to the base image name (without extension, e.g. "cockatrice", +; as accepted by Get-Process -Name), call this, result in $R0 +; (1 = running from $INSTDIR, 0 = not running) +Function IsAppRunning + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"' + Pop $R0 + ${If} $R0 = 0 + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function CloseMatchingApps + ; gracefully ask every matching instance to close (sends WM_CLOSE) + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function ForceCloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"' + Pop $R3 +FunctionEnd + +Function WaitForAppToClose + ; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the + ; base image name (e.g. "cockatrice") + Call IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + + ${If} ${Silent} + ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit + DetailPrint "Closing $R1 ..." + Call CloseMatchingApps + StrCpy $R8 0 + ck_wait_loop: + Sleep 500 + IntOp $R8 $R8 + 1 + Call IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R8 < 60 + Goto ck_wait_loop + ${EndIf} + ; give up waiting, force close + DetailPrint "Force closing $R1 ..." + Call ForceCloseMatchingApps + Sleep 500 + ${Else} + ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL ck_abort_install + Call IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + Goto ck_wait_prompt + ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +Function EnsureAppsNotRunning + StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" + Call WaitForAppToClose + StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" + Call WaitForAppToClose + StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" + Call WaitForAppToClose +FunctionEnd + +; Uninstaller copies of the same routines (the uninstaller gets its own +; function set compiled in, it cannot call the installer functions). +Function un.IsAppRunning + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"' + Pop $R0 + ${If} $R0 = 0 + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function un.CloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function un.ForceCloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"' + Pop $R3 +FunctionEnd + +Function un.WaitForAppToClose + Call un.IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + + ${If} ${Silent} + DetailPrint "Closing $R1 ..." + Call un.CloseMatchingApps + StrCpy $R8 0 + un_ck_wait_loop: + Sleep 500 + IntOp $R8 $R8 + 1 + Call un.IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R8 < 60 + Goto un_ck_wait_loop + ${EndIf} + DetailPrint "Force closing $R1 ..." + Call un.ForceCloseMatchingApps + Sleep 500 + ${Else} + un_ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL un_ck_abort_install + Call un.IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + Goto un_ck_wait_prompt + un_ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +Function un.EnsureAppsNotRunning + StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" + Call un.WaitForAppToClose + StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" + Call un.WaitForAppToClose + StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" + Call un.WaitForAppToClose +FunctionEnd + Function PortableModePageCreate ${If} $ReinstallMode = 1 @@ -318,6 +492,26 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" RMDir "$INSTDIR" ${EndIf} +; Belt and braces: the old uninstaller may have already run in the /R path, so +; ensure no application instance is still holding file locks, then remove any +; runtime DLLs left over from older versions. A mismatched Qt/OpenSSL set next +; to the new executable is what causes "The procedure entry point X could not be +; located in the dynamic link library ...Qt6Network.dll" after an update. +Call EnsureAppsNotRunning + +${If} $PortableMode = 0 +${AndIf} ${FileExists} "$INSTDIR\cockatrice.exe" + RMDir /r "$INSTDIR\Plugins" + Delete "$INSTDIR\Qt*.dll" + Delete "$INSTDIR\libcrypto*.dll" + Delete "$INSTDIR\libssl*.dll" + Delete "$INSTDIR\zlib*.dll" + Delete "$INSTDIR\libmysql.dll" + Delete "$INSTDIR\icu*.dll" + Delete "$INSTDIR\libeay32.dll" + Delete "$INSTDIR\ssleay32.dll" +${EndIf} + @CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@ @CPACK_NSIS_FULL_INSTALL@ diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 7cf58d3e0..46151481c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -5,11 +5,13 @@ #include "../client/network/update/client/release_channel.h" #include "../interface/window_main.h" +#include #include #include #include #include #include +#include #include #include #include @@ -240,8 +242,23 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) // Try to open the installer. If it opens, quit Cockatrice if (process.startDetached()) { - QMetaObject::invokeMethod(static_cast(parent()), "close", Qt::QueuedConnection); qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice"; + // Close the main window synchronously so file locks are released before the NSIS installer + // (already launched) starts replacing files. This also flushes settings and shuts down the + // tabs, but only when the close is actually accepted: MainWindow may veto it for a running + // card DB update, an open game, or an unsaved deck. In that case keep running so the user + // can resolve the blocker, and tell them the installer is already waiting. + if (auto *window = qobject_cast(parent())) { + if (window->close()) { + QTimer::singleShot(0, qApp, &QCoreApplication::quit); + } else { + QMessageBox::warning(this, tr("Update"), + tr("The update installer is already running and will finish the update once " + "Cockatrice closes. Cockatrice is still busy, so it stays open for now.")); + } + } else { + QTimer::singleShot(0, qApp, &QCoreApplication::quit); + } close(); } else { setLabel(tr("Error"));