Zablokowanie możliwości zamknięcia dynamicznie zaalokowanej konsoli systemowej

0

Przepisuję właśnie system logów w swoim silniku. Jeśli gra zostanie uruchomiona z określonym parametrem, silnik alokuje systemowe okno konsoli, za pomocą funkcji AllocConsole. Potem blokuję systemowy przycisk do zamykania okna — funkcja DeleteMenu robi robotę. Aby skrót Ctrl+C nie ubijał konsoli i przy okazji gry, dodaję pusty handler funkcją SetConsoleCtrlHandler. Wszystko gra, te opcje stają się niedostępne.

Pozostała jeszcze jedna rzecz — blokowanie skrótu Alt+F4. Ma ktoś pojęcie jak ten skrót zablokować, tak aby nie dało się w żaden sposób zamknąć dynamicznie utworzonego okna konsoli? Chcę, aby konsola była zamykana wyłącznie po zamknięciu okna gry.

0

Dobra, sprawdziłem z procedurą okna i dupa — nie da się subclassować konsoli, każda próba rejestracji własnej procedury okna kończy się niepowodzeniem. Nie da się tego zrobić, bo konsola jest kontrolowana przez zewnętrzny proces — csrss.exe.


To czego potrzebuję to po prostu sposobu na wykrycie próby zamknięcia okna konsoli (przyciskiem, skrótami klawiszowymi) i jej zablokowanie, tak aby nie została ona zamknięta. To co trzeba zrobić w przypadku takiej próby to ją odrzucić, a następnie poinformować kod silnika, aby odpowiednio na taką próbę zareagował i albo pozwolił zamknąć grę, albo nie.

Potrzebuję tego z dwóch powodów. Po pierwsze, silnik musi obsłużyć zamknięcie na swój sposób, czyli przerwać główną pętlę gry i sfinalizować swoje działanie, czyli przede wszystkim zapisać ważne dane. Może też taką próbę zignorować, aby gra nie została zamknięta. Po drugie, zamknięcie konsoli musi być blokowane, bo jeśli konsola zostanie zamknięta, to proces gry wali błędem, co mnie wkurza:

screenshot-20240303112910.png

Tak samo to wygląda w przypadku, gdy stworzy się normalną aplikację okienkową w LCL, ale ustawi się {$APPTYPE CONSOLE} (albo przełącznik -WG). Zamknięcie okna konsoli np. krzyżykiem powoduje dokładnie taki sam błąd.

0

Zgaduję że to nie jest nic interaktywnego w tej konsoli, tylko wyświetlasz tam informacje do debugowania. Okna konsoli zostały stworzone dla aplikacji konsolowych, dlatego też na przykład aplikacja może mieć tylko jedną konsolę. Może zrób po prostu drugie okno graficzne i wyświetlaj w nim tekst na zasadzie konsoli, proste przewijanie też nie powinno być trudne. Może nawet jest do tego jakaś biblioteka.

4

@Manna5: musi być wsparcie systemowej konsoli. Po pierwsze dlatego, że ona już istnieje, ma wiele ficzerów itd., więc nie muszę jej implementować ręcznie. Po drugie dlatego, że system logów silnika zapisuje dane na standardowe wyjście za pomocą WriteLn, a tym może być konsola lub plik dyskowy. Po trzecie dlatego, że użytkownik może odpalić grę z poziomu konsoli oraz określić, że logi mają być kierowane właśnie do konsoli, a nie do pliku.

Takie rozwiązanie ładnie współpracuje z systemem i daje dużo więcej możliwości. 😉


Znalazłem rozwiązanie — blokadę na skróty klawiszowe Ctrl+C oraz Alt+F4 (i dowolne inne) należy założyć za pomocą niskopoziomowego hooka na klawiaturę. Microsoft przygotował na ten temat artykuł — Disabling Shortcut Keys in Games. Na nim się wzorowałem, choć aby hook faktycznie działał, nie należy testować wParam. Niżej opiszę szczegółowo o tym jak to wykonałem, w razie gdyby ktoś kiedyś tego potrzebował.

Nie wiadomo czy gra została odpalona z dysku czy z poziomu konsoli, więc najpierw należy sprawdzić, czy na się podpiąć pod konsolę procesu-rodzica. Jest wiele sposobów na wykrycie tego czy program został odpalony z poziomu konsoli, ale najprostszy to użycie funkcji AttachConsole i sprawdzenie czy operacja się powiodła:

uses
  Windows;

{...}

if AttachConsole(ATTACH_PARENT_PROCESS) then
  // Program został odpalony z poziomu konsoli i udało się pod nią podczepić.

Jeśli nie udało się podczepić pod konsolę, to znaczy, że program albo nie został uruchomiony z poziomu konsoli, albo system nie umiał go do niej podczepić. Skoro nie ma dostępnej konsoli, to należy ją stworzyć — tutaj wjeżdża funkcja AllocConsole:

if AllocConsole() then
  // Udało się stworzyć konsolę dla programu.

Jeśli nie uda się stworzyć konsoli, to logi kierujemy do pliku. Albo do d**y — uchwyt standardowego wyjścia jest zainicjalizowany, więc wszystko co się wypisze za pomocą WriteLn do niego trafi, a że na nic sensownego nie jest ustawiony, to po prostu te dane przepadną.

Natomiast jeśli konsolę uda się stworzyć, należy poinformować RTL Free Pascala, że program używa konsoli i trzeba zaktualizować uchwyty, m.in. do standardowego wyjścia. Wystarczą dwie linijki:

IsConsole := True;
SysInitStdIO();

Od teraz proces ma konsolę do dyspozycji. Aby użytkownik nie mógł jej zamknąć i zabić procesu naszego programu, trzeba mu taką możliwość odebrać. Najpierw należy zablokować przycisk na belce okna konsoli:

var
  ConsoleHandle: HANDLE
  ConsoleMenu:   HMENU;

{...}

// Pobierz uchwyt okna konsoli.
ConsoleHandle := GetConsoleWindow();

if ConsoleHandle <> 0 then
begin
  // Pobierz uchwyt systemowego menu okna konsoli.
  ConsoleMenu := GetSystemMenu(ConsoleHandle, False);

  if ConsoleMenu <> 0 then
    // Zablokuj przycisk do zamykania okna (z krzyżykiem).
    EnableMenuItem(GetSystemMenu(ConsoleHandle, False), SC_CLOSE, MF_BYCOMMAND or MF_DISABLED or MF_GRAYED);
end;

Przyciskiem nie da się zamknąć konsoli, bo stał się wyszarzony — nie ma też opcji Close w menu kontekstowym belki okna. Ale konsolę można jeszcze zamknąć dwoma skrótami klawiszowymi — Ctrl+C oraz Alt+F4. Ten pierwszy można zablokować poprzez ustawienie pustego handlera, za pomocą funkcji SetConsoleCtrlHandler:

SetConsoleCtrlHandler(nil, True);

Ale to tylko jeden skrót, a trzeba zablokować dwa. Zamiast powyższego, należy założyć low level keyboard hook — w nim można podsłuchiwać i blokować cały input z klawiatury, a także ”zjadać” wszystko co chcemy (w tym pojedyncze klawisze oraz skróty klawiszowe).

Najpierw należy zdefiniować funkcję przetwarzającą input (nasz hook):

function ConsoleKeyboardHook(nCode: LongInt; wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall;

Ten callback należy zarejestrować, po udanej alokacji lub podłączeniu się pod konsolę:

const
  WH_KEYBOARD_LL = 13;
var
  ConsoleHook: HHOOK;

{...}

ConsoleHook := SetWindowsHookEx(WH_KEYBOARD_LL, @ConsoleKeyboardHook, GetModuleHandle(nil), 0);

Jeśli system zarejestruje nasz hook to spoko, a jeśli nie to trudno — ta funkcja nie będzie wywoływana. Sam niczego nie potrzebuję robić w przypadku, gdy rejestracja hooka się nie powiedzie, więc to tyle. Następnie należy oprogramować funkcję podsłuchującą:

function ConsoleKeyboardHook(nCode: LongInt; wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall;
type
  KBDLLHOOKSTRUCT = record
    vkCode:      DWORD;
    scanCode:    DWORD;
    flags:       DWORD;
    time:        DWORD;
    dwExtraInfo: ULONG_PTR;
  end;
var
  HookStruct: ^KBDLLHOOKSTRUCT absolute lParam;
begin
  if nCode = HC_ACTION then
    // Jeśli aktywne jest okno konsoli, sprawdź input, w przeciwnym razie przekaż dane do kolejnego hooka.
    if GetForegroundWindow() = ConsoleHandle then
    begin
      // Jeśli wciśnięto skrót "Alt+F4" lub "Ctrl+C", zwróć "1" — input nie dotrze do okna konsoli.
      if (HookStruct^.vkCode = VK_F4) and (GetAsyncKeyState(VK_MENU)    and $8000 <> 0) then exit(1);
      if (HookStruct^.vkCode = VK_C)  and (GetAsyncKeyState(VK_CONTROL) and $8000 <> 0) then exit(1);
    end;

  // Przekaż dane do kolejnego hooka (niezwykle istotne).
  Result := CallNextHookEx(ConsoleHook, nCode, wParam, lParam);
end;

W tej funkcji nie należy testować wParam przeciwko WM_KEYDOWN i WM_KEYUP (tak jak w artykule od Microsoftu), bo w przypadku tych skrótów klawiszowych, wParam tych wartości nie zawiera i blokada skrótów będzie nieskuteczna. Bardzo istotne jest to, aby ten callback wywoływał CallNextHookEx dla każdego innego inputu, którego nie blokujemy — w przeciwnym razie nasz hook zablokuje input w całym systemie. 🤣

Od tego momentu konsoli nie będzie się dało nijak zamknąć, w przynajmniej nie w normalny, znany użytkownikowi sposób. Oczywiście inicjalizacja tego wszystkiego musi iść w parze z finalizacją. Na koniec sesji programu trzeba po sobie posprzątać — wyrejestrować hook na klawiaturę, odblokować systemowy przycisk do zamykania konsoli (jeśli nasz program podłączył się pod istniejącą) lub zniszczyć konsolę (jeśli sam ją alokował). Ręczne niszczenie konsoli nie jest wymagane, dlatego że system sam ją zniszczy, jeśli nasz program był ostatnim do niej podłączonym.

// Wyrejestruj hook na klawiaturę.
UnhookWindowsHookEx(ConsoleHook);

// Odblokuj z powrotem przycisk zamykania konsoli, w razie gdyby system jej nie zniszczył.
ConsoleMenu := GetSystemMenu(ConsoleHandle, False);

if ConsoleMenu <> 0 then
  EnableMenuItem(GetSystemMenu(ConsoleHandle, False), SC_CLOSE, MF_BYCOMMAND or MF_ENABLED);

// Zniszcz konsolę. Jeśli inny proces się pod nią podłączył, konsola nie zostanie zamknięta, dlatego
// ważne jest to, aby odblokować systemowy przycisk zamykania konsoli.
FreeConsole();

No i to w sumie tyle. W przypadku mojego silnika, po wykryciu próby ubicia konsoli, funkcja hooka dodaje zdarzenie SDL_QUITEVENT do kolejki SDL-a. W kolejnej klatce gry, funkcja przetwarzająca zdarzenia SDL-a napotka na SDL_QUITEVENT i w razie czego zakończy działanie głównej pętli gry, dzięki czemu gra będzie mogła sfinalizować swoje działanie (zapisać dane oraz sprzątnąć zasoby).

Jeśli powyższego używamy w standardowej aplikacji okienkowej (stworzonej w LCL), w callbacku hooka można w dowolny sposób poinformować aplikację o próbie jego zamknięcia — odpalić Application.Terminate, wysłać jej komunikat WM_CLOSE lub cokolwiek innego. Oczywiście można też zeżreć te skróty i nie informować aplikacji — w ten sposób konsolę będzie można zamknąć tylko po zamknięciu głównego okna programu (system zamknie konsolę lub odłączy od niej naszą aplikację, jeśli ta podłączyła się pod konsolę, zamiast ją alokować).


PS: stałą WH_KEYBOARD_LL oraz typ KBDLLHOOKSTRUCT deklarowałem ręcznie, bo nie ma ich w module Windows.

1 użytkowników online, w tym zalogowanych: 0, gości: 1