Dostęp do konsoli z aplikacji GUI

Popełniłem sobie małego arta na temat dostępu do konsoli spod aplikacji GUI, opisującego jak najprościej rozróżnić czy aplikacja okienkowa została uruchomiona spod konsoli i jak do niej się dorwać, choćby po to by wyświetlić dostępne opcje linii poleceń naszej aplikacji.

Główna inspiracja do wgłębienia się w ten temat była obsługą linii poleceń w aplikacjach napisanych z wykorzystaniem toolkita wxWidgets. Tam właśnie dostępne opcje linii poleceń aplikacji GUI wyświetlane są w MessageBoxie, nawet, jeśli uruchamiamy ja spod konsoli. Oczywiście mi takie rozwiązanie nie pasowało. Chciałem bliżej zapoznać się z możliwościami konsoli dostępnych w WinAPI dla aplikacji GUI, ale w gogle nie znalazłem nic wartego uwagi i rozwiązującego mój problem. Tylko pobieżne informacje, dzięki którym sam zabrałem się do roboty ;)

Jedynie, co mi jeszcze pozostało to napisanie patche’a do wxWidgets wyświetlającego w konsoli informacje zwrotne z obsługi linii poleceń, gdy naszą aplikacje uruchomimy wprost spod konsoli.

Wprowadzenie

Mamy aplikacje okienkowa (GUI) z obsługą linii poleceń, która czasem uruchamiany spod konsoli, głównie w przypadku, kiedy chcemy podać jakieś argumenty, czy opcje właśnie poprzez linie poleceń. Problem pojawia się przy wyświetlaniu informacji zwrotnych lub helpa (–help). Standardowo nie mamy możliwości, aby ładnie zaprezentować te informacje na konsoli, z której uruchomiono naszą okienkową aplikacje. A właśnie taki efekt chcielibyśmy osiągnąć.

WinAPI posiada dosyć rozbudowane funkcje do obsługi konsoli, odpowiednie ich wykorzystanie pozwoli nam osiągnąć zamierzony cel. Ale zacznijmy od początku ;)

O konsoli i jej wykorzystaniu w programach i grach można znaleźć informacje w artykule „Asynchroniczna konsola Windows” autorstwa Regedita.

Najprostszym sposobem jest pobranie uchwytu konsoli i wysłanie do niej odpowiedniego stringa z naszym tekstem:

DWORD dwWritten;
char* text = "dupa123";
HANDLE hStdErr = GetStdHandle(STD_OUTPUT_HANDLE);
WriteConsole(hConsole, text, strlen(text), &dwWritten, 0);

Jeśli uruchomimy naszą aplikacje spod konsoli:

C:\Documents and Settings\malcom>app.exe

To teoretycznie na konsoli powinna pojawić się treść naszego komunikatu, ale nic takiego nie miało miejsca, dlaczego? Ano, dlatego, że shell nie czeka na zakończenie procesu naszej aplikacji, jedynie go uruchamia, tak jakby w niejawnej, niewidocznej instancji konsoli.

Uruchomienie z odpowiednim przełącznikiem interpretera poleceń – /C powinno rozwiązać ten problem, ale niestety nie udało mi się tego dokonać:

C:\Documents and Settings\malcom>cmd /C app.exe

Wykrywanie konsoli

Jak najprościej rozróżnić czy uruchomiliśmy aplikację spod konsoli, czy wprost spod GUI systemu? Wystarczy pobrać uchwyt do standardowego wyjścia błędów, jeśli zwrócona wartość będzie prawidłowa to aplikacje uruchomiono spod konsoli:

HANDLE hStdErr = GetStdHandle(STD_ERROR_HANDLE);
if (hStdErr && hStdErr != INVALID_HANDLE_VALUE) {
	DWORD dwWritten;
	char* text = "uruchomiona spod konsoli";
	WriteConsole(hStdErr, text, strlen(text), &dwWritten, 0);
} else {
	MessageBox(0, "uruchomiono spod GUI", 0, MB_OK);
}

O dziwo, tylko STD_ERROR_HANDLE przy aplikacji uruchomionej z GUI systemu zwróci wartość INVALID_HANDLE_VALUE, uchwyty do standardowego wyjścia i wejścia zawsze posiadają poprawną wartość, dlatego nie nadają się do jednoznacznego odróżnienia sposobu uruchomienia.

Przechwytywanie konsoli

Dalej jednak nie otrzymujemy naszego komunikatu na konsoli. Od wersji XP systemu Windows (_WIN32_WINNT >= 0x0501), WinAPI wzbogaciło się o ciekawą funkcje – AttachConsole – dzięki, której możemy „podpiąć” się pod konsole dowolnego procesu uruchomionego w systemie. W naszym przypadku, chodzi nam o proces rodzica, wszakże, nasza konsola jest rodzicem, jeśli uruchomimy aplikacje spod shella, wiec przed jakimikolwiek operacjami na konsoli wystarczy wywołać:

AttachConsole(ATTACH_PARENT_PROCESS);

A po zakończeniu zabaw z konsolą, należy ją odpowiednio „odłączyć” wywołując:

FreeConsole();

Teraz wypisanie czegokolwiek na konsoli się powiedzie i zobaczymy nasz komunikat. Tylko efekt nadal nas nie zadowala. Nasz tekst został wypisany za znakiem zachęty:

C:\Documents and Settings\malcom>app.exe
 
C:\Documents and Settings\malcom>app uruchomiona spod konsoli

Jak już wspomniałem, dzieje się to przez nieczekanie shella na zakończenie procesu uruchomionej aplikacji GUI. Gdy uruchomimy aplikacje spod konsoli z opcja /C, nasz ekran będzie wyglądał tak:

C:\Documents and Settings\malcom>cmd /C app.exe
app uruchomiona spod konsoli
C:\Documents and Settings\malcom>

czyli tak jak chcieliśmy.

Zatem co zrobić, aby w obu przypadkach efekt był taki samy? Możemy trochę się pobawić funkcjami do obsługi konsoli, aby taki efekt uzyskać.

Po krótkiej analizie obydwóch wyników dochodzimy do wniosku, że najprościej pobrać tekst z konsoli od ostatniego wystąpienia pustej linii, a następnie wyczyścić ten obszar, na koniec ustawiając pozycje kursora na początku tego obszaru.

Musimy zwarcic uwagę na to, że długość ciągu reprezentującego aktualny katalog i znak zachęty nie koniecznie musi mieścić się w jednej linii. Zatem powinniśmy po kolei sprawdzać linie, aż do napotkania pustej linii. Za pusta linie przyjmujemy linie, której 4 pierwszych znaków to spacje. Łatwo to dokonać w prostej pętli:

char buf[4];
do {
	pos.Y--;
	if(!ReadConsoleOutputCharacter(hStdErr, buf, sizeof(buf), pos, &ret))
		break;
} while (strncmp("    ", buf, 4) != 0);

Zmienna pos jest struktura typu COORD zawierającą współrzędne pozycji ostatniej linii, która razem z innymi informacjami można pobrać funkcją GetConsoleScreenBufferInfo.

Gdy już znajdziemy poszukiwana pustą linie, obliczamy długość danych jaka musimy pobrać i je pobieramy poprzez funkcję ReadConsoleOutputCharacter, poczym czyścimy ten obszar:

FillConsoleOutputCharacter(hStdErr, ' ', len, pos, &ret);

Po wyczyszczeniu musimy jeszcze tylko za pomocą funkcji SetConsoleCursorPosition ustawić kursor w pozycji, gdzie zaczynał się obszar, który właśnie wyczyściliśmy.

Teraz śmiało możemy wypisać treść naszego komunikatu na konsoli. A na koniec oczywiście nie możemy zapomnieć o wypisaniu odczytanych danych, które powinny znaleźć się na końcu ;)

To nie nasza konsola!

Teraz pojawia się jeszcze jeden problem. Konsola nie jest nasza! W każdej chwili ktoś może z niej skorzystać, po uruchomieniu naszej aplikacji, wydając jakieś polecenie czy uruchamiając inny program. Wtedy wysłanie czegokolwiek może spowodować bałagan, mieszanie się output’ów z kilku procesów. Dlatego po wykryciu, że konsola po uruchomieniu naszej aplikacji została użyta do innych celów powinniśmy uznać, że jest już niedostępna dla naszego programu.

Jak to łatwo zrealizować?

Najprościej jest sprawdzić czy historia wprowadzonych poleceń się zmieniła. W Windows Vista wprowadzono funkcję nową funkcję GetConsoleHistoryInfo, która dostarcza nam potrzebne informacje o historii zawarte w strukturze CONSOLE_HISTORY_INFO, ale nie skorzystamy z niej.

Jak wiadomo nie wszystkie dostępne funkcje API są udokumentowane w MSDN. Większość systemowych bibliotek DLL eksportuje funkcje, o których nawet wzmianki nie znajdziemy w dokumentacji.

W naszym przypadku w kernel32.dll znajdziemy kilka funkcji operujących na konsoli wykorzystywanych w programie doskey, służącym do edycji wiersza poleceń, w tym obsługa historii wprowadzanych poleceń (np. dodający opcje pod klawiszami Fx).

Nam potrzebne są tylko dwie funkcje o prototypach:

DWORD GetConsoleCommandHistory(LPTSTR sCommands, DWORD nBufferLength, LPTSTR sExeName);
DWORD GetConsoleCommandHistoryLength(LPTSTR sExeName);

Jak się można łatwo domyślić, pierwsza kopiuje do bufora sCommands o długości nBufferLength zawartość wewnętrznego bufora konsoli zawierającej historie poleceń, i zwraca ilość skopiowanych danych. Druga zwraca długość danych historii. W parametrze sExeName przekazujemy string cmd.exe.

Wersja ANSI tych funkcji trochę dziwnie się zachowywują na XP-ku i Viście. Otóż funkcja GetConsoleCommandHistoryLengthA zwraca poprawnie wymaganą długość bufora, ale funkcja GetConsoleCommandHistoryA zwraca już 2 razy większą ilość skopiowanych znaków. Czyżby mały bug?

Wracając do tematu, porównując historie poleceń z danymi pobranymi przy starcie programu, możemy jednoznacznie stwierdzić, czy konsola jest jesz dla nas dostępna.

Nie poruszono tutaj specyficznych sytuacji takich jak przekierowanie strumieni na konsoli, bądź uruchamianie naszej aplikacji przez pliki BAT.

Sytuacje związaną z uruchomieniem naszej aplikacji przez plik bat, można by rozwiązać poprzez sprawdzenie ostatniego wydanego polecenia w konsoli.

Typowe uruchomienie programu można dokonać na jeden z kilku sposobów:

app param
app.exe
\foo\bar\app.exe
C:\foo\bar\app.exe
"C:\foo\bar\app.exe"

Najprostsze wyrażenie regularne rozwiązałoby sprawę:

^((.*)?)app((.exe)?)(( (.*))?)$

Klasa Stderr

Korzystając z zamieszczonych wyżej informacji, można łatwo opakować wszystko w klasę, i w prosty sposób korzystać z konsoli, wyświetlać komunikaty i sprawdzać jej dostępność. O większą funkcjonalność można ją łatwo rozbudować, ale nie to jest tematem tego artykułu ;)

Kod klasy można tymczasowo podejrzeć tutaj, interfejs wraz z implementacją klasy zawarto jednym pliku, dla łatwego używania, należy wydzielić interfejs do pliku nagłówkowego. A poniżej mały przykładzik.

#include <windows.h>
#include <stdlib.h>
#include <tchar.h>
#include "stderr.h"
 
int APIENTRY _tWinMain(HINSTANCE hInstance,
						HINSTANCE hPrevInstance,
						LPTSTR lpCmdLine, int nCmdShow)
{
	Stderr console;
	TCHAR* text = _T("Tekst komunikatu.");
 
	if (console.IsAvailable())
		console.Write(text, _tcslen(text));
	else
		MessageBox(0, text, 0, MB_OK);
 
	return 0;
}

Zakończenie

Teraz w prosty sposób możemy uzbroić naszą okienkową aplikację w obsługę linii poleceń, a wszelkie błędne opcje oraz pomoc dotyczącą dostępnych spod linii poleceń opcji zaprezentować w ładny i prosty sposób na konsoli, z której próbujemy uruchomić naszą aplikacje.

Jedynym minusem jest migniecie tekstu na konsoli, który czyścimy, ale myślę, że to malutki drobiazg.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *