diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 12185adda..6ce765a35 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -38,6 +38,9 @@ Var AbortReason Var HolderExit Var HolderOutput Var HolderFound +; OfferToCloseAndRetryPurge counts the accepted close-and-retry rounds, so the +; prompt can stop repeating an offer that provably did not help (see there). +Var HolderCloseRounds Var WaitPid Var LauncherExited Var InstallLogPath @@ -766,8 +769,16 @@ Function CloseMatchingApps ; or one owned by another account running as SYSTEM) as a match would make ; the installer close a third party application, and it runs elevated, so ; CloseMainWindow() would succeed on it. + ; + ; The window is activated first, because WM_CLOSE is answered, not obeyed: + ; Cockatrice replies with a confirmation of its own while a card database + ; update is running, a game is open or a deck is unsaved. Without the + ; activation that dialog opens behind the installer's own prompt, where it + ; looks like the close did nothing and the second WM_CLOSE is delivered to a + ; main window its own modal dialog has disabled, which is ignored. Failing to + ; activate is harmless and swallowed: the close request still goes out. System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { try { $$null = (New-Object -ComObject WScript.Shell).AppActivate($$_.Id) } catch { }; $$null = $$_.CloseMainWindow() }"' Pop $R3 FunctionEnd @@ -1185,15 +1196,74 @@ Function CheckStaleRuntimeMask ${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. +; 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 @@ -1253,12 +1323,14 @@ FunctionEnd ; ; The probe opens each survivor for DELETE access and then closes it again ; without deleting anything. The code is read by the plugin right after that call -; (?e), so a failure reports what Windows refused; clearing LastError first is -; what makes a zero code mean "this open worked", i.e. the purge-time failure was -; transient (an antivirus scan that has finished, an instance still shutting -; down) rather than a standing condition. The plugin parses the call's arguments -; between the clear and the call, so a non-zero code is reported as what Windows -; said rather than as proof of a cause - the wording below never claims one. +; (?e), so a failure reports what Windows refused; clearing LastError first makes +; a zero code mean "the open worked". That is all it means: Windows lets a DLL a +; running process has mapped be opened for DELETE, and DeleteFile still refuses +; it until the image is unloaded, so a zero code is not evidence that the +; failure was transient - the holders line is what names a running process. The +; plugin parses the call's arguments between the clear and the call, so a +; non-zero code is reported as what Windows said rather than as proof of a +; cause - the wording below never claims one. Function LogStaleRuntimeDiagnostics FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" ${If} $StaleFindHandle != "" @@ -1275,11 +1347,12 @@ Function LogStaleRuntimeDiagnostics ${Else} Pop $R2 ${If} $R2 = 0 - ; A valid handle: close it. The open proves the file is not held - ; open, but not that Delete would succeed: a read-only file opens - ; for DELETE and still fails DeleteFile until the attribute is - ; cleared, which the purge does not do. Report that standing - ; cause instead of calling the failure transient. + ; A valid handle: close it. The open 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 (the purge clears it before every attempt + ; now, so a survivor that is read-only had it set again). Report + ; that standing cause instead of calling the failure transient. System::Call 'kernel32::CloseHandle(p R1)' System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' ${If} $R3 = -1 @@ -1287,9 +1360,9 @@ Function LogStaleRuntimeDiagnostics ${Else} IntOp $R3 $R3 & 1 ${If} $R3 = 1 - StrCpy $R0 "a delete-access open now succeeds, but it is still read-only, which Delete does not clear" + StrCpy $R0 "a delete-access open now succeeds, but it is read-only, which stops Delete" ${Else} - StrCpy $R0 "a delete-access open now succeeds" + StrCpy $R0 "a delete-access open now succeeds, but a DLL a running process has mapped still refuses Delete - see holders" ${EndIf} ${EndIf} ${Else} @@ -1360,6 +1433,125 @@ Function LogProcessesUsingInstallDir !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 - ask, close what this +; installer owns, purge again, and retry while the user keeps asking. Sets +; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts +; when the files survive. +; +; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the +; window's close button) and 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, exactly as the +; running-instance guard treats it. The instance is brought forward first, so that +; confirmation is on screen rather than behind this installer's prompt. Oracle and +; Servatrice have no window to send a close to at all, and nothing here is +; force-stopped: Oracle writes the card database in place and killing it mid-write +; would corrupt it. Everything the probe named that this installer cannot close - +; those two included - is left to the user, whom the prompt names it to. +; +; 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. +; +; No offer when nobody is at the keyboard (a /S install without /R, e.g. a package +; manager): a dialog there would block forever. /R is the in-app updater, whose +; client has already quit, so the user is there - the same reasoning AbortInstall +; uses for its message box. No offer either when the probe named nobody: there is +; nothing to close, and the abort's per-file diagnostics already record what +; Windows said. +; +; The first round offers the close; every later one stops offering it and says +; what to do instead. Repeating the same question is what made an accepted close +; look like a Yes that was ignored: when Cockatrice was blocked on its own +; confirmation, the second Yes was delivered to a window that dialog had disabled, +; so nothing closed, the holders were unchanged, and the identical prompt came +; back with no hint of what had happened. +Function OfferToCloseAndRetryPurge + ${If} $ReinstallMode = 0 + ${AndIf} ${Silent} + !insertmacro CockLog "close-holders: unattended install, not offering to close holders" + Return + ${EndIf} + ${If} $HolderFound != 1 + !insertmacro CockLog "close-holders: no holder was named, nothing to offer" + Return + ${EndIf} + StrCpy $HolderCloseRounds 0 + ocrp_prompt: + ${If} $HolderCloseRounds = 0 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nThe installer can close Cockatrice for you. It may first ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose Yes to close Cockatrice and try again, or No to cancel the installation." \ + IDYES ocrp_close + !insertmacro CockLog "close-holders: declined by user" + Return + ${EndIf} + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nCockatrice may be waiting for an answer in its own window - it asks before quitting while a card database update is running, a game is open or a deck is unsaved. Switch to it and confirm, then close Oracle, Servatrice and any other program listed above.$\r$\n$\r$\nChoose Retry once they are closed, or Cancel to cancel the installation." \ + IDRETRY ocrp_close + !insertmacro CockLog "close-holders: declined by user after $HolderCloseRounds round(s)" + Return + ocrp_close: + IntOp $HolderCloseRounds $HolderCloseRounds + 1 + !insertmacro CockLog "close-holders: accepted (round $HolderCloseRounds), holders were [$HolderOutput]" + ; The close is a request, not a fact: AskCockatriceAppsToClose waits for the + ; instances it asked to exit, so a confirmation of Cockatrice's that is still up is + ; reported as still running instead of being measured as a successful close. + Call AskCockatriceAppsToClose + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLogListing "after-close-purge" "*.dll" + !insertmacro CockLogListing "after-close-plugins" "Plugins\*.*" + !insertmacro CockLog "close-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 next prompt and the log describe what + ; survived the close and not what was there before it. + Call LogStaleRuntimeDiagnostics + ${If} $HolderFound != 1 + Return + ${EndIf} + Goto ocrp_prompt +FunctionEnd + +; Asks the running Cockatrice to close, and waits for it to exit. +; +; The set is the Restart Manager answer from LogProcessesUsingInstallDir: exactly the +; processes holding a file of the version being replaced. tools\installer-holder-probe +; closes that set by pid, which is why nothing here has to match an executable path or +; depend on powershell.exe. Only cockatrice.exe is asked: Oracle writes the card database +; in place and Servatrice has no window, so both stay the user's job, as the prompt says. +; +; Each instance is brought to the foreground before WM_CLOSE is posted to its main +; window. WM_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 purge 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. +Function AskCockatriceAppsToClose + InitPluginsDir + ; LogProcessesUsingInstallDir extracted it already; the check keeps a single copy of + ; the executable in the installer and still works if that order ever changes. + IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_close_probe_ready + File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" + ck_close_probe_ready: + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' + ; 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 + !insertmacro CockLog "close-holders: probe exit=$R3 [$R4]" + DetailPrint "Closing Cockatrice: $R4" +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 @@ -1786,13 +1978,21 @@ ${If} $IsUpgrade = 1 ${Else} StrCpy $StaleRuntimeAfterPurge 0 ${EndIf} +${If} $StaleRuntimeAfterPurge = 1 + !insertmacro CockLog "section: files of a previous version survived the purge" + ; Who is holding the payload is recorded first, so the log that reaches us + ; answers the question the message can only guess at, and so the prompt can + ; name them and offer to close the ones this installer owns. + Call LogStaleRuntimeDiagnostics + ; On /R the previous version is already uninstalled at this point, so an abort + ; leaves the user with no working Cockatrice. Offer to close and purge again, + ; repeating while the user keeps saying Yes; this updates + ; $StaleRuntimeAfterPurge with the last retry's verdict. + Call OfferToCloseAndRetryPurge +${EndIf} ${If} $StaleRuntimeAfterPurge = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." - ; Who is holding the payload is recorded before the abort, so the log that - ; reaches us answers the question the message can only guess at, and so the - ; message can name the processes to close whenever any were found. - Call LogStaleRuntimeDiagnostics ; The in-app updater runs this installer silent and detached and never looks ; at its exit code, so the user has to be told what happened. A /S install ; without /R is unattended (a package manager, for instance) and a message @@ -1803,6 +2003,12 @@ ${If} $StaleRuntimeAfterPurge = 1 ${If} $HolderFound = 1 StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" ${EndIf} + ; "Run the installer again" is not enough on an in-app update: the old version + ; was uninstalled before the purge, so point at the installer that was downloaded. + ${If} $ReinstallMode = 1 + ${AndIf} $PortableMode = 0 + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nThe previous version has already been removed. To finish the update, run this installer again:$\r$\n$EXEPATH" + ${EndIf} Call AbortInstall ${EndIf} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index 3098669cc..ab3bb3eff 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -4,7 +4,7 @@ # the installer and is extracted to $PLUGINSDIR there, never installed, so it links # nothing from this project: no Qt, and a static C runtime because the installed runtime # is exactly what the purge may have failed to replace. -add_executable(installer-holder-probe holder_line.cpp main.cpp restart_manager_holders.cpp) +add_executable(installer-holder-probe close_holders.cpp holder_line.cpp main.cpp restart_manager_holders.cpp) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp new file mode 100644 index 000000000..508d9b3c1 --- /dev/null +++ b/tools/installer-holder-probe/close_holders.cpp @@ -0,0 +1,212 @@ +#include "close_holders.h" + +// clang-format off +// windows.h has to come before anything that pulls in a Windows type, as everywhere in +// this tool. +#include +// clang-format on + +#include +#include +#include +#include + +namespace +{ +constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe"; + +/// How long one poll of the asked processes takes. One WaitForSingleObject per process +/// and no enumeration, so polling costs nothing next to the seconds this is called on. +constexpr unsigned long WAIT_POLL_MILLISECONDS = 200; + +/// How long the report line may get before it is cut. NSIS holds one string in 1024 +/// characters, and this one is written there verbatim; every holder is named, so normally +/// the line stays far below it. +constexpr std::size_t REPORT_CHAR_MAX = 900; + +bool isCockatriceImage(const std::wstring &imageName) +{ + return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; +} + +/// The holder as the log has to name it to be actionable. Deliberately not +/// formatHolder(): that one is worded for the dialog, where ", running" says something +/// the line here continues after. +std::wstring processLabel(const Holder &holder) +{ + std::wstring label = holder.imageName.empty() ? holder.appName : holder.imageName; + if (label.empty()) { + label = L"an unnamed process"; + } + label += L" (pid "; + label += std::to_wstring(holder.processId); + label += L")"; + return label; +} + +struct WindowSearch +{ + DWORD processId = 0; + HWND topmost = nullptr; ///< first visible window in Z-order: a dialog when one is up + HWND main = nullptr; ///< first visible window without an owner: the one to close + HWND any = nullptr; ///< first window at all, used when nothing is visible +}; + +BOOL CALLBACK findProcessWindow(HWND window, LPARAM parameter) +{ + auto &search = *reinterpret_cast(parameter); + DWORD processId = 0; + GetWindowThreadProcessId(window, &processId); + if (processId != search.processId) { + return TRUE; + } + if (search.any == nullptr) { + search.any = window; + } + // A tray icon leaves the main window hidden, so a window that is not visible is still + // kept as a fallback. EnumWindows walks the Z-order top down, so the first visible + // window is the one the user would see in front. + if (IsWindowVisible(window) != FALSE) { + if (search.topmost == nullptr) { + search.topmost = window; + } + if (search.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + search.main = window; + } + } + return TRUE; +} + +/// Restores a minimized window and takes the foreground, so a confirmation Cockatrice +/// answers the close with is in front of the installer instead of behind it. +/// +/// A process in the background may not take the foreground on its own. Attaching to the +/// thread that owns the foreground window lifts that restriction for the duration of the +/// call; detaching afterwards leaves the two input queues as they were. +bool bringForward(HWND window) +{ + if (IsIconic(window) != FALSE) { + ShowWindow(window, SW_RESTORE); + } else { + ShowWindow(window, SW_SHOW); + } + + const HWND foreground = GetForegroundWindow(); + const DWORD foregroundThread = foreground != nullptr ? GetWindowThreadProcessId(foreground, nullptr) : 0; + const DWORD currentThread = GetCurrentThreadId(); + const bool attached = foregroundThread != 0 && foregroundThread != currentThread && + AttachThreadInput(currentThread, foregroundThread, TRUE) != FALSE; + BringWindowToTop(window); + SetForegroundWindow(window); + if (attached) { + AttachThreadInput(currentThread, foregroundThread, FALSE); + } + return GetForegroundWindow() == window; +} + +/// Waits for @p processes to signal, up to @p waitMilliseconds. Returns whether they all +/// exited and how long the wait took. +bool waitForExit(const std::vector &processes, unsigned long waitMilliseconds, unsigned long long &elapsed) +{ + const ULONGLONG start = GetTickCount64(); + const ULONGLONG deadline = start + waitMilliseconds; + for (;;) { + bool allExited = true; + for (const HANDLE process : processes) { + if (WaitForSingleObject(process, 0) != WAIT_OBJECT_0) { + allExited = false; + break; + } + } + elapsed = GetTickCount64() - start; + if (allExited || GetTickCount64() >= deadline) { + return allExited; + } + Sleep(WAIT_POLL_MILLISECONDS); + } +} +} // namespace + +CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +{ + CloseHoldersResult result; + std::vector lines; + std::vector asked; + std::vector askedLines; + + for (const Holder &holder : holders) { + if (holder.isService || !isCockatriceImage(holder.imageName)) { + // Oracle and Servatrice are named by the prompt and close by hand: Oracle + // writes the card database in place. A service has no window to ask either. + lines.push_back(processLabel(holder) + L" left to you"); + continue; + } + + WindowSearch search; + search.processId = holder.processId; + EnumWindows(findProcessWindow, reinterpret_cast(&search)); + if (search.any == nullptr) { + lines.push_back(processLabel(holder) + L" has no window to close, left to you"); + continue; + } + + const HWND target = + search.main != nullptr ? search.main : (search.topmost != nullptr ? search.topmost : search.any); + const HWND toRaise = search.topmost != nullptr ? search.topmost : target; + std::wstring line = processLabel(holder); + line += bringForward(toRaise) ? L" brought forward" : L" could not be brought forward"; + + // Kept open across the wait so a pid reused after the process exits cannot be + // mistaken for the instance that was asked to close. + const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId); + const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS; + if (PostMessageW(target, WM_CLOSE, 0, 0) == FALSE) { + // Read the error before CloseHandle, which is free to reset it. + const DWORD postError = GetLastError(); + if (process != nullptr) { + CloseHandle(process); + } + lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) + + L")"); + continue; + } + line += L" and asked to close"; + ++result.askedToClose; + if (process == nullptr) { + line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")"; + } else { + askedLines.push_back(lines.size()); + asked.push_back(process); + } + lines.push_back(std::move(line)); + } + + if (!asked.empty() && waitMilliseconds > 0) { + unsigned long long elapsed = 0; + const bool allExited = waitForExit(asked, waitMilliseconds, elapsed); + for (std::size_t index = 0; index < asked.size(); ++index) { + const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0; + lines[askedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" + : L", still running after " + std::to_wstring(elapsed) + L" ms"; + } + } + for (const HANDLE process : asked) { + CloseHandle(process); + } + + if (lines.empty()) { + result.report = L"nothing is holding the files"; + return result; + } + for (const std::wstring &line : lines) { + if (!result.report.empty()) { + result.report += L"; "; + } + result.report += line; + } + if (result.report.size() > REPORT_CHAR_MAX) { + result.report.erase(REPORT_CHAR_MAX); + result.report += L"..."; + } + return result; +} diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h new file mode 100644 index 000000000..427da5ea6 --- /dev/null +++ b/tools/installer-holder-probe/close_holders.h @@ -0,0 +1,46 @@ +#ifndef CLOSE_HOLDERS_H +#define CLOSE_HOLDERS_H + +#include "holder_line.h" + +#include +#include + +/** + * @brief What asking the holders to close did, in the form the installer logs. + * + * @c report is one line naming every holder, including the ones this deliberately + * leaves alone, because the installer repeats it in its log and in the second prompt. + */ +struct CloseHoldersResult +{ + unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to + std::wstring report; ///< one line, narrowed to ASCII when written +}; + +/** + * @brief Brings every running Cockatrice holding an installer file forward and asks it + * to close. + * + * The set is the Restart Manager answer, so it is exactly the processes holding a file of + * the previous version: nothing here has to guess at an executable path, and an + * application that merely shares the image name is never touched. Only cockatrice.exe is + * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card + * database in place - and so is every service. + * + * Two things happen per instance. Its top-level window is restored and brought to the + * foreground, because Cockatrice may answer the close with a confirmation of its own that + * would otherwise open behind the installer's prompt, and then WM_CLOSE is posted to the + * main window, the same request as clicking its close button. Stopping the process is + * never attempted. + * + * Once the requests are out, this waits up to @c waitMilliseconds for the processes to + * exit, so the caller can measure the purge immediately afterwards instead of sleeping + * for a fixed time or re-asking a question this call already answered. + * + * @param holders the Restart Manager answer, one entry per application or service + * @param waitMilliseconds how long to wait for the asked instances to exit, 0 to not wait + */ +CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); + +#endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 3b5bb5d22..269d22821 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -1,3 +1,4 @@ +#include "close_holders.h" #include "holder_line.h" #include "restart_manager_holders.h" @@ -12,8 +13,16 @@ namespace /// trailing backslash then never has to survive NSIS quoting or a command line. constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; +/// How long the close mode waits for the instance it asked to exit before it reports the +/// process as still running. The installer measures the purge right after this call, so +/// the wait belongs here: it is the only place that holds a handle on the process whose +/// exit it is waiting for. +constexpr unsigned long CLOSE_WAIT_MILLISECONDS = 30000; + /// Exit codes the installer reads. "Something was found" is deliberately not 0, so an /// answer of "nothing holds the files" stays distinguishable from a probe that failed. +/// In the close mode 1 means a close was actually sent and 0 that every holder was left +/// to the user, which is what the report line next to the code spells out. enum ExitCode { EXIT_NO_HOLDERS = 0, @@ -22,6 +31,20 @@ enum ExitCode EXIT_PROBE_FAILED = 3, }; +/// The probe has two modes, selected by the first argument: +/// +/// (none) name what holds the install directory, one line, for the abort +/// dialog and the log +/// --close-holders bring the running Cockatrice forward, ask it to close, and wait for +/// it to exit, for the offer to close after a failed purge +/// +/// Both answer from the same Restart Manager query, so the close can only ever reach a +/// process that is holding a file of the version being replaced. +bool closeModeRequested(int argumentCount, char **arguments) +{ + return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; +} + /// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes /// come back to the installer as an ANSI string, so a non-ASCII process name would be /// mojibake in the log; a '?' at least stays honest. Control characters are dropped @@ -54,7 +77,7 @@ std::wstring installDirFromEnvironment() } } // namespace -int main() +int main(int argumentCount, char **arguments) { const std::wstring installDir = installDirFromEnvironment(); if (installDir.empty()) { @@ -68,11 +91,23 @@ int main() writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); return EXIT_PROBE_FAILED; } + + const bool closeMode = closeModeRequested(argumentCount, arguments); if (holders.empty()) { - // No output: the installer words the "nothing is holding them" case, and it - // owns the message either way. + // No output in the naming mode: the installer words the "nothing is holding them" + // case, and it owns the message either way. The close mode says it itself, because + // its line is logged verbatim next to what it was asked to do. + if (closeMode) { + writeLine(L"nothing is holding the files"); + } return EXIT_NO_HOLDERS; } - writeLine(formatHolders(holders)); - return EXIT_HOLDERS_FOUND; + if (!closeMode) { + writeLine(formatHolders(holders)); + return EXIT_HOLDERS_FOUND; + } + + const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS); + writeLine(result.report); + return result.askedToClose > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; }