mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Offer to end running Cockatrice immediately when auto-close fails * [Installer] Keep the way out that costs nothing in the first prompt The prompt now offers to end Cockatrice, so what the deleted close-again text said has to survive in it: an instance the probe reports as waiting for an answer to a question of its own has been brought forward, and answering that window is the way out that ends nothing. Say so, and say where to go when Yes leaves it listed - Task Manager - so an end that cannot take effect does not loop on the same dialog. * [Installer] Bring the comments back in line with the code GuardCloseTried no longer counts closes, and the file header, CloseHolders and GuardAppsNotRunning still described an escalation after a second close, as did the probe's --end-holders note. * [Installer] End Oracle and Servatrice too when the guard's end answer is confirmed The guard's end answer only ever stopped cockatrice.exe, so an Oracle holding the card database or a Servatrice service kept the payload locked and the prompt came back with nothing left that could free it. The probe now ends every program the payload installs, services included: isPayloadProgram() - the list restart_manager_holders already used for its parent walk - decides the set, so a scanner holding a DLL or a program the user keeps in the install directory is still named and left to the user. A service is terminated rather than sent a stop through the SCM, which notices the process exit and marks it stopped afterwards. The guard prompt now says what ending costs: an unsaved deck, an unwritten setting, players on a running Servatrice, and an Oracle mid-way through the card database. It also keeps the notification-area hint, which the probe's "has no window to ask" answer depends on, and drops the claim that Oracle and Servatrice had been brought forward. * [Installer] Give every running-program prompt the same end-or-retry answers Four prompts meet a running program and each offered a different set: the guard ended it, the launcher wait only kept waiting, and the recovery prompt after a failed purge could only ask Cockatrice to close. With Oracle or Servatrice holding the files, that last one had no answer that could free anything - Yes and No both retried the purge and Cancel aborted an install whose old version was already gone. All four now follow the guard's shape: one automatic attempt, then a prompt whose Yes ends the programs it names and whose No retries, with Cancel to give up. - RecoverFromHolders closes once and purges before it asks anything, then offers EndHolders - the same end answer as the guard - instead of a close the user had to trigger himself. - WaitForLauncherToExit can end the launcher by pid, the one process it holds a pid for, rather than looping 60 s waits with Cancel as the only way out. - END_COST and AFTER_CLOSING_GUIDANCE are !defines, so the cost of ending and the guidance for the answers that end nothing read the same in every prompt. * [Installer] End the launcher through the handle it was waited on The "end it" answer used to look the launcher's pid up again after a dialog that can stay open as long as the user takes, so a pid that had been reused in the meantime - or a /PID= that was never the launcher - would be terminated instead. The handle the wait already holds names that process and cannot be handed to another, so the terminate answer now acts on it. PROCESS_TERMINATE is requested along with PROCESS_SYNCHRONIZE; when only the synchronize right is granted the launcher is still waited on and the answer falls back to asking it to close, instead of reporting a live launcher as gone and writing the payload over it. * [Installer] Say what the end answer ends, and report a failed end The prompt offered "end every program listed above", but EndHolders only ends the programs the payload installs: a scanner holding a payload DLL, or a service the Restart Manager names by its long name, was listed and then silently left alone, so Yes looked like it did nothing and the same dialog came back unchanged. The wording now says which programs Yes ends and that anything else has to be closed by hand, and the round after an end that did not free the files says so, so the same dialog no longer reappears word for word. The three prompts are one macro so they cannot drift apart again. The added sentences eat into the 1024 characters one NSIS string holds, so the holder line's budget drops from 400 to 320 characters to keep the longest message (cancel line included) inside it: 977 characters, measured against the worst case the probe can produce. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
371 lines
15 KiB
C++
371 lines
15 KiB
C++
#include "close_holders.h"
|
|
|
|
#include "restart_manager_holders.h"
|
|
|
|
// clang-format off
|
|
// windows.h has to come before anything that pulls in a Windows type, as everywhere in
|
|
// this tool.
|
|
#include <windows.h>
|
|
// clang-format on
|
|
|
|
#include <cstddef>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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;
|
|
}
|
|
|
|
/// Joins the per-holder lines into the one line the installer logs and cuts it to what one
|
|
/// NSIS string holds.
|
|
std::wstring joinReport(const std::vector<std::wstring> &lines)
|
|
{
|
|
std::wstring report;
|
|
for (const std::wstring &line : lines) {
|
|
if (!report.empty()) {
|
|
report += L"; ";
|
|
}
|
|
report += line;
|
|
}
|
|
if (report.size() > REPORT_CHAR_MAX) {
|
|
report.erase(REPORT_CHAR_MAX);
|
|
report += L"...";
|
|
}
|
|
return report;
|
|
}
|
|
|
|
struct WindowSearch
|
|
{
|
|
DWORD processId = 0;
|
|
std::vector<HWND> windows; ///< every top-level window of the process, in Z-order
|
|
};
|
|
|
|
BOOL CALLBACK collectProcessWindow(HWND window, LPARAM parameter)
|
|
{
|
|
auto &search = *reinterpret_cast<WindowSearch *>(parameter);
|
|
DWORD processId = 0;
|
|
GetWindowThreadProcessId(window, &processId);
|
|
if (processId == search.processId) {
|
|
search.windows.push_back(window);
|
|
}
|
|
return TRUE;
|
|
}
|
|
|
|
/// Whether a window is one the user could be asked to answer.
|
|
///
|
|
/// Only a framed, non-empty window qualifies. Qt keeps hidden top-level windows of its own
|
|
/// next to the application's: the tray icon creates one to receive the TaskbarCreated
|
|
/// message, styled WS_OVERLAPPED - which is no frame at all - and titled
|
|
/// QTrayIconMessageWindow. Showing one of those puts an empty white rectangle on the
|
|
/// taskbar that nothing ever closes again: it has no window procedure of its own to hide it,
|
|
/// and the process is busy in the loop this is meant to break, so it stays until the process
|
|
/// dies. Posting a close to one is worse than useless too: Qt's tray window answers WM_CLOSE
|
|
/// by asking the application to terminate, without going through the confirmation the close
|
|
/// is supposed to reach.
|
|
bool isAnswerableWindow(HWND window)
|
|
{
|
|
if ((GetWindowLongPtr(window, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) {
|
|
return false;
|
|
}
|
|
if ((GetWindowLongPtr(window, GWL_STYLE) & WS_CAPTION) == 0) {
|
|
return false;
|
|
}
|
|
RECT bounds{};
|
|
if (GetWindowRect(window, &bounds) == FALSE) {
|
|
return false;
|
|
}
|
|
return bounds.right > bounds.left && bounds.bottom > bounds.top;
|
|
}
|
|
|
|
/// Picks the window to post the close to and the one the user should end up looking at.
|
|
struct CloseTarget
|
|
{
|
|
HWND main = nullptr; ///< first visible window without an owner: the application's own
|
|
HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up
|
|
bool modalOpen = false; ///< the window a close would reach is disabled, so a dialog of its own is up
|
|
};
|
|
|
|
/// Only the windows the user could answer are candidates - see isAnswerableWindow. An
|
|
/// instance left with nothing but Qt's own bookkeeping windows visible has no window to ask:
|
|
/// the caller reports that instead of posting a close the application would answer with a
|
|
/// terminate that skips its own confirmation.
|
|
CloseTarget chooseCloseTarget(const std::vector<HWND> &windows)
|
|
{
|
|
CloseTarget target;
|
|
HWND topmost = nullptr;
|
|
for (const HWND window : windows) {
|
|
if (IsWindowVisible(window) == FALSE || !isAnswerableWindow(window)) {
|
|
continue;
|
|
}
|
|
if (topmost == nullptr) {
|
|
topmost = window;
|
|
}
|
|
if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) {
|
|
target.main = window;
|
|
}
|
|
}
|
|
target.toRaise = topmost != nullptr ? topmost : target.main;
|
|
// A modal dialog disables the windows behind it, so a disabled main window is the
|
|
// instance asking a question of its own.
|
|
target.modalOpen =
|
|
target.toRaise != nullptr && IsWindowEnabled(target.main != nullptr ? target.main : target.toRaise) == FALSE;
|
|
return target;
|
|
}
|
|
|
|
/// What is left of the window the close was posted to, so a log line tells an instance that
|
|
/// answered by hiding from one that never answered at all. A process that hid its window and
|
|
/// stayed alive is the one that needs the next close to bring it back into view.
|
|
std::wstring describeWindowState(HWND window)
|
|
{
|
|
if (window == nullptr || IsWindow(window) == FALSE) {
|
|
return L", its window is gone";
|
|
}
|
|
// WM_NULL is the cheapest way to ask whether the thread behind the window still dispatches.
|
|
// A hung instance does not answer at all, which is a different problem from one that
|
|
// answered the close and stayed open, and the two want different words in the prompt.
|
|
DWORD_PTR ignored = 0;
|
|
const bool responding =
|
|
SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &ignored) != 0;
|
|
std::wstring state = IsWindowVisible(window) != FALSE ? L", window still open" : L", window hidden again";
|
|
if (!responding) {
|
|
state += L" and not responding";
|
|
}
|
|
return state;
|
|
}
|
|
|
|
/// 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<HANDLE> &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<Holder> &holders, unsigned long waitMilliseconds)
|
|
{
|
|
CloseHoldersResult result;
|
|
std::vector<std::wstring> lines;
|
|
std::vector<HANDLE> asked;
|
|
std::vector<std::size_t> askedLines;
|
|
std::vector<HWND> askedWindows;
|
|
|
|
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(collectProcessWindow, reinterpret_cast<LPARAM>(&search));
|
|
if (search.windows.empty()) {
|
|
lines.push_back(processLabel(holder) + L" has no window to close, left to you");
|
|
continue;
|
|
}
|
|
|
|
const CloseTarget target = chooseCloseTarget(search.windows);
|
|
if (target.main == nullptr) {
|
|
// Nothing visible that a user could answer: the instance is hidden - in the
|
|
// notification area, say - or has nothing but Qt's own bookkeeping windows on
|
|
// screen. Neither is a window a close could reach, and showing one from here does
|
|
// not help: Windows would display it while the application still believes it is
|
|
// hidden, so it stays blank and answers nothing. The instance is named and left to
|
|
// the user, who can open it from the notification area.
|
|
lines.push_back(processLabel(holder) + L" has no window to ask, left to you");
|
|
continue;
|
|
}
|
|
std::wstring line = processLabel(holder);
|
|
line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward";
|
|
|
|
// Posting the close to an instance that is asking a question of its own is what made
|
|
// an earlier attempt useless: the client accepts it, hides this window and the dialog
|
|
// with it, and stays alive inside the nested loop the dialog opened, where nothing can
|
|
// reach it any more. The question is the request now, so it is brought forward and
|
|
// left alone.
|
|
if (target.modalOpen) {
|
|
lines.push_back(line + L", and is waiting for an answer to a question of its own");
|
|
continue;
|
|
}
|
|
|
|
// 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.main, 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);
|
|
askedWindows.push_back(target.main);
|
|
}
|
|
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;
|
|
if (exited) {
|
|
lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms";
|
|
} else {
|
|
lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" +
|
|
describeWindowState(askedWindows[index]);
|
|
}
|
|
}
|
|
}
|
|
for (const HANDLE process : asked) {
|
|
CloseHandle(process);
|
|
}
|
|
|
|
if (lines.empty()) {
|
|
result.report = L"nothing is holding the files";
|
|
return result;
|
|
}
|
|
result.report = joinReport(lines);
|
|
return result;
|
|
}
|
|
|
|
EndHoldersResult endHolders(const std::vector<Holder> &holders, unsigned long waitMilliseconds)
|
|
{
|
|
EndHoldersResult result;
|
|
std::vector<std::wstring> lines;
|
|
std::vector<HANDLE> ended;
|
|
std::vector<std::size_t> endedLines;
|
|
|
|
for (const Holder &holder : holders) {
|
|
if (!isPayloadProgram(holder.imageName)) {
|
|
// Not one of the programs the payload installs: a scanner that has a DLL open, or
|
|
// a program the user keeps in the install directory. It is named and left to the
|
|
// user, because ending it would destroy something this installer does not own.
|
|
lines.push_back(processLabel(holder) + L" left to you");
|
|
continue;
|
|
}
|
|
|
|
// Services included: the installer asked before getting here, and the Service
|
|
// Control Manager reports the terminated process as a stopped service afterwards.
|
|
const HANDLE process = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, holder.processId);
|
|
if (process == nullptr) {
|
|
lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(GetLastError()) +
|
|
L")");
|
|
continue;
|
|
}
|
|
if (TerminateProcess(process, 1) == FALSE) {
|
|
// Read the error before CloseHandle, which is free to reset it.
|
|
const DWORD error = GetLastError();
|
|
CloseHandle(process);
|
|
lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(error) + L")");
|
|
continue;
|
|
}
|
|
++result.ended;
|
|
endedLines.push_back(lines.size());
|
|
lines.push_back(processLabel(holder) + L" ended");
|
|
ended.push_back(process);
|
|
}
|
|
|
|
if (!ended.empty() && waitMilliseconds > 0) {
|
|
unsigned long long elapsed = 0;
|
|
const bool allExited = waitForExit(ended, waitMilliseconds, elapsed);
|
|
for (std::size_t index = 0; index < ended.size(); ++index) {
|
|
const bool exited = allExited || WaitForSingleObject(ended[index], 0) == WAIT_OBJECT_0;
|
|
lines[endedLines[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 : ended) {
|
|
CloseHandle(process);
|
|
}
|
|
|
|
if (lines.empty()) {
|
|
result.report = L"nothing is holding the files";
|
|
return result;
|
|
}
|
|
result.report = joinReport(lines);
|
|
return result;
|
|
}
|