Piszemy własny instalator w C
11.06.2015 | aktual.: 13.06.2015 14:33
W tym wpisie opiszę to, jak stworzyłem instalator dla Libgreatttao. Instalator w zamierzeniu ma obsługiwać tylko x86 i x86_64, jako iż Libgreattao nie ma wsparcia dla Androida. Dzięki takiemu założeniu mogłem go napisać w C. Postanowiłem także użyć komponentów, które są dostępne raczej w każdym systemie Uniksowym, czyli biblioteki standardowej C i podstawowych wywołań/programów Uniksowych. Instalator korzysta z programów uname, tar, which, tput i tty.
Napisałem również skrypt tworzący instalator, jako iż przy wydaniu kolejnej wersji Libgreattao może zaistnieć potrzeba stworzenia nowego instalatora. Skrypt ten wykonuje w odpowiednich katalogach make clean, make, mkdir, tar, touch, make install. Niekiedy wykona xdg‑terminal Skrypt ten sprawdza czy posiadamy architekturę x86_64, a jeśli nie, to generuje jedynie archiwum tar‑a dla x86.
Makefile
Do utworzenia instalatora stworzyłem plik Makefile, które w sumie nie będzie potrzebny - polecenia z Makefile można umieścić w skrypcie tworzącym instalator. Skrypt tworzący instalator wykonuje make w katalogu ze źródłami instalatora. Oto, jak wygląda ten Makefile:
tao_installer: main.c tarbal32 tarbal64 gcc main.c -o tao_installer -Wl,--format=binary -Wl,LICENSE -Wl,tarbal32 -Wl,tarbal64 -Wl,--format=default
Przed poleceniem gcc jest umieszczony tabulator. Umieszczenie w tym miejscu tabulatora jest wymagane! Pierwsza linijka określa nam, kiedy program make, w momencie wywołania w katalogu z naszym Makefil-em, ma wykonać drugą linijkę. Będzie to, jeżeli któryś z plików o jednej z nazw wymienionych po dwukropku będzie nowszy od pliku tao_installer. Druga linijka to wywołanie kompilatora języka c. Kompilator wymaga pliku main.c, a przekazuje do linkera plik LICENSE, tarbal32, tarbal64. -‑format=binary oznaczy, że linker nie będzie zwracać uwagi na format pliku - po prostu doda go do wygenerowanego pliku wykonywalnego. Każdy kolejny plik przekazany do linkera będzie traktowany jako plik w formacie binarym. -‑format=default przełącza linkera w tryb dodawania plików w formacie domyślnym. Jest to konieczne, gdyż kompilator w wywołaniu linkera umieści na końcu plik obiektowy, który został wygenerowany z pliku main.c.
Uruchamianie programów konsolowych
W tym rozdziale nadmienię o dwóch niezwykle ważnych sztuczkach. Każdą z nich wykorzystaliśmy w instalatorze lub w skrypcie do wygenerowania instalatora. Obiema sztuczkami nie muszą się martwić programiści aplikacji pod Windows, bo Windows dba o te sprawy. Zaimplementowanie jednak tych rzeczy jest niezwykle proste. Poniżej zamieszczam spis tych sztuczek:
- Tworzenie okna terminala, gdy nie jest ono dostępne
- Przejście do katalogu z plikiem wykonywalnym uruchomionego programu
Obie sztuczki przedstawię, jako kawałek kodu powłoki bash. Oto pierwsza ze sztuczek:
tty > /dev/null if [ ! $? -eq 0 ]; then xdg-terminal "env SPAWN_TERMINAL=TRUE \"$0\"" exit 0 fi
Program tty zwraca 1, jeśli standardowe wejście nie jest terminalem, a 0 w przypadku, gdy nim jest. W logice bash-a 0 oznacza prawdę, a wszystko inne fałsz. Jest to spowodowane tym, że kody powrotu sygnalizują typ błędu lub jego brak. Druga linijka sprawdza kod powrotu( $? ). Sprawdza czy nie jest (!) równy (‑eq) 0.. Jeżeli nie jest równy 0, to wykonuje xdg‑terminal. Xdg‑terminal uruchamia graficzny emulator terminala naszego środowiska graficznego, uruchamiając w nim polecenie przekazane jako pierwszy parametr. Nawiasy podwójne pozwalają na podstawianie w nich zmiennych. My wykorzystujemy argument przekazany naszemu skryptowi na pozycji 0. W systemach Uniksowych przyjęto, że pierwszy argument, to ścieżka do uruchomionego pliku, a powłoki chyba zawsze przekazują w parametrze 0 tą ścieżkę. Umieściliśmy ten argument między ciągami \", jako iż ścieżka może zawierać spacje. Backslash w tych ciągach jest konieczny, jako iż znak cudzysłowia zamknąłby ciąg przekazywany za ciągiem xdg‑terminal. env SPAWN_TERMINAL=TRUE ustawia dla nowo tworzonego procesu(przez xdg‑terminal) zmienną środowiskową SPAWN_TERMINAL na ciąg TRUE. Umożliwia na to powstrzymanie zamknięcia okna terminala, wykonując polecenie read, o tak:
if [ "$SPAWN_TERMINAL" == "TRUE" ]; then echo Naciśnij dowolny klawisz, by zamknąć te okno # Napis ten przetłumaczyłem z języka angielskiego - w moim skrypcie jest on po angielsku read -t 20 pause fi
exit 0 powoduje wyjście, by dalsze instrukcje się nie wykonały. Podsumowując: proces uruchamia swój program w oknie konsoli, jeśli nie został uruchomiony w konsoli. Druga sztuczka polega na przejściu do bieżącego katalogu. Ta sztuczka jest wykorzystywana tylko przez generator instalatora. Jest to konieczne, gdyż w skrypcie odwołujemy się do ścieżek relatywnych, a skrypt mógł zostać uruchomiony z dowolnego katalogu(środowiska graficzne uruchamiają wszystko z katalogu / ). Oto jak prosta jest to sztuczka:
cd "`dirname "$0"`"
cd zmienia obecny katalog, a dirname "$0" zwraca ścieżkę do katalogu naszego programu. Ścieżka ta będzie absolutna lub relatywna, ale niezależnie od tego zadziała, gdyż ścieżka relatywna zostanie doklejona na koniec obecnego katalogu. Jeśli uruchomiliśmy skrypt a.sh, w taki sposób katalog/do/a/a.sh w katalogu /home/nintyfan, to dirname zwróci katalog/do/a/, a cd wykona operację sklejenia, która zwróci /home/nintyfan/katalog/do/a/. Jeśli jednak podaliśmy ścieżkę /home/nintyfan/katalog/do/a/a.sh z katalogu /home/ktosik, to dirname zwróci /home/nintyfan/katalog/do/a, a cd nie wykona sklejenia i przejdzie do /home/nintyfan/katalog/do/a.
Ważniejsze elementy instalatora
Instalator wykorzystuje tę samą sztuczkę, co skrypt generujący instalator, jednak tutaj trzeba wykorzystać funkcje języka C i Uniksowe funkcje, jak system, fork, execlp, a także makra WIFEXIT, WEXITSTATUS. Funkcja system uruchamia domyślną powłokę użytkownika, przekazując jej ciąg znaków, jako polecenia do wykonania. Zwraca ona informacje o tym, czy wykonanie polecenia się powiodło, i z jakim kodem powrotu. Makro WIFEXIT sprawdza czy przekazany jej argument zawiera informację o tym, że polecenie zostało uruchomione i zakończyło się normalnie. WEXITSTATUS zwraca kod powrotu z tej samej informacji, jeśli wywołanie makra, które opisałem przed chwilą, zwraca prawdę. Jeżeli chcemy sprawdzić czy domyślne wejście nie jest terminalem, to poniższe wystarczy:
int is_spawned_in_console = system("tty &> /dev/null"); if (WIFEXITED(is_spawned_in_console) && WEXITSTATUS(is_spawned_in_console) == 1) { // nie terminal }
Do bloku kodu instrukcji if możemy wkleić następującą treść:
execlp("xdg-terminal", "xdg-terminal", argv[0], NULL); fprintf(stderr, "xdg-terminal program not installed! Aborting!\n"); exit(1);
Execlp przyjmuje nazwę programu do uruchomienia, a także listę parametrów do przekazania, zakończoną wartością NULL. argv[0], to pierwszy argument przekazany do naszego programu, czyli sposób, w jakim został wywołany(panuje tutaj analogia do skryptów bash). Execlp przy braku błędu nie powraca. Kolejną rzeczą jest sprawdzenie efektywnego identyfikatora użytkownika. W systemach Uniksowych każdy proces ma trzy identyfikatory użytkownika, jednak ja nie będę ich opisywać. Zwrócę tylko uwagę, że efektywny UID zawiera informacje o tym, na prawach jakiego użytkownika działa program. Sprawdzenie efektywnego identyfikatora użytkownika wykonuje się funkcja geteuid. Musimy sprawdzić czy nie jest równy zero, i jeżeli to prawda, to uruchomić sudo w ten sam sposób, co uruchomiliśmy xdg‑terminal. Trzeba zachować tą samą kolejność sprawdzania tych warunków, co w tym artykule.
Obsługa plików zasobów
Pamiętasz, jak doklejaliśmy do wynikowego pliku z programu linker, własne pliki? Dla każdego takiego pliku są tworzone co najmniej dwa symbole "_binary_ŚCIEŻKA_DO_PLIKU_start" i "_binary_ŚCIEŻKA_DO_PLIKU_end", przy czym znaki niedozwolone w etykietach assemblerowych, jak kropka czy slash, są zamieniane na znaki podłóg. By utworzyć zmienną wskazującą na początek tekstu licencji, to należy się posłużyć takim kodem:
extern char license_text_start[] asm("_binary_LICENSE_start");
Extern mówi kompilatorowi, żeby nie szukał tego symbolu w bieżącym pliku.
By wypakować plik zasobów, to należy posłużyć się mkstemp i write. Nie można także zapomnieć o wywołaniu close. Do write przekazujemy, jako drugi argument wskaźnik na początek pliku w zasobach, a jako trzeci długość tego pliku. By obliczyć długość pliku, to należy rzutować oba wskaźniki na koniec i początek naszego pliku w zasobach na typ (intptr_t), który jest w pliku nagłówkowych unistd.h. Do mkstemp należy przekazać tablicę znaków(powtarzam: tablicę znaków, a nie wskaźnik na ciąg znaków, jako iż literały znakowe są umieszczane w pamięci bez praw do zapisu), której ostatnie sześć znaków to X. Oto przykład:
char license_text_path[] = {'/', 't', 'm', 'p', '/', 'l', 'i', 'b', 'g', 'r', 'e', 'a', 't', 't', 'a', 'o', '-', 'l', 'i', 'c', 'e', 'n', 's', 'e', '-', 'X', 'X', 'X', 'X', 'X', 'X'}; fd = mkstemp(license_text_path); write(fd, license_text_start, (intptr_t)license_text_end - (intptr_t)license_text_start); close(fd);
Sprawdzanie architektury
Do sprawdzenia architektury trzeba się posłużyć poleceniem uname -m. Jest też oczywiście funkcja dostępna w C, pod UNIKSEM, które zwraca te informacje. Jednak, by nie mieszać za bardzo, postanowiłem użyć uname -m. Dla prostoty wybrałem popen języka C i fgets(również języka C), zamiast korzystać z uniksowych fork, pipe, dup2, read, execlp. Są sytuacje, w których lepiej posłużyć się drugą metodą, bo np. przekazujemy parametr wywołania naszego procesu do wywoływanego programu, jednak w tym wypadku lepiej posłużyć się popen i fgets. Trzeba jeszcze usunąć znak nowego wiersza z końca. W źródłach instalatora jest funkcja get_command_output_without_last_newline, która załatwia wszystko.
Po otrzymaniu architektury, to należy sprawdzić, czy początek archiwum tar dla tej architektury nie jest jej końcem(czyli czy archiwum nie było tak naprawdę pustym plikiem). Powód jest taki, że być może nie będziemy posiadać kompilatora obsługującego jedną z architektur, więc skrypt generujący archiwum tar, wygeneruje zamiast tego pusty plik. Jeżeli archiwum było plikiem pustym, to trzeba wybrać inną obsługiwaną architekturę przez ten procesor i ponowić próbę. W przeciwnym wypadku zapisujemy wskaźnik na koniec i początek pliku do specjalnych zmiennych. Oto przykład:
if (strcmp(architecture, "x86") == 0) { if ((intptr_t) tarbal32_end -(intptr_t) tarbal32_start == 0) { system("tput setaf 1"); fprintf(stderr, "Unsupported architecture type\n"); system("tput sgr0"); exit(1); } tarbal_start = tarbal32_start; tarbal_end = tarbal32_end; } else if (strcmp(architecture, "x86_64") == 0) { if ((intptr_t) tarbal64_end -(intptr_t) tarbal64_start == 0) { system("tput setaf 1"); fprintf(stderr, "x86_64 processors are not supported. Switching to x86\n"); if ((intptr_t) tarbal32_end -(intptr_t) tarbal32_start == 0) { fprintf(stderr, "Unsupported architecture type\n"); system("tput sgr0"); exit(1); } system("tput sgr0"); tarbal_start = tarbal32_start; tarbal_end = tarbal32_end; } else { tarbal_start = tarbal64_start; tarbal_end = tarbal64_end; } } else { system("tput setaf 1"); fprintf(stderr, "Unkown architecture. Exiting\n"); system("tput sgr0"); exit(1); }
Trochę przydługi przykład, prawda? No, cóż - nie chciało mi się refaktoryzować, gdyż obsługujemy tylko dwie architektury.; Dodanie obsługi np. arm‑ów wymagałoby skorzystanie z Javy lub z FatELF, jednak Java nie musi być zainstalowana na każdym systemie, a FatELF nie zostało włączone do vanilii. Wykorzystujemy tutaj tput. Służy to do koloryzowania komunikatów. Informacje o tput znajdują się na anglojęzycznej Wikipedii.
Wyświetlanie licencji
Do wyświetlania licencji należy się posłużyć się jakimś pagerem. Pager powinien być wskazywany przez zmienną środowiskową PAGER. Jeżeli nie, to należy za pomocą popen("which less", "r") i popen("which more", "r") sprawdzić obecność pagerów less i more(wynik popen należy przekazać do fgets, a następnie usunąć z bufora, do którego zapisał w fgets znak nowej linii, by w ten sposób uzyskać ścieżkę do pagera). Następnie wybieramy jeden z dostępnych. Po wypakowaniu licencji z zasobów, możemy ją wyświetlić, przekazując do execlp ścieżkę do pagera lub zawartość zmiennej PAGER, jako pierwszy i drugi argument, a jako trzeci należy przekazać ścieżkę do pliku z licencją.. Musimy się także posłużyć funkcją wait. Oto przykład:
if (answer == 'r') { if (fork() == 0) { char buffer[PATH_MAX]; pagerprog = getenv("PAGER"); if (pagerprog) { execlp(pagerprog, pagerprog, license_text_path, NULL); } get_command_output_without_last_newline("which less", buffer, PATH_MAX); execlp(buffer, buffer, license_text_path, NULL); get_command_output_without_last_newline("which more", buffer, PATH_MAX); execlp(buffer, buffer, license_text_path, NULL); fprintf(stderr, "Unable to open any pager\n"); exit(1); } if (wait(&status) == -1) { perror("ERROR IN WAIT "); } if (WIFEXITED(status)) { if (WEXITSTATUS(status) == 1) { exit(1); } } else { fprintf(stderr, "Error while executing program to show license text"); } }
Funkcja get_command_output_without_last_newline pobiera wynik polecenia i usuwa nową linię z końca. Możesz przeczytać zawartość tej funkcji ze źródeł libgreattao, podkatalogu Installer, pliku main.c. Zmienna answer, w przypadku mojego programu, przyjmuje wartość 'r', gdy użytkownik wybrał przeczytanie licencji.
Właściwa instalacji
Dla właściwej instalacji, należy zmienić bieżący katalog na /usr/local, bo tam powinny być trzymane wszelkie programy nie dostarczone z dystrybucją, a następnie wykonać:
execlp("tar", "tar", "--extract", "-f", tarbal_path, NULL);
tarbal_path powinno przechowywać ścieżkę do archiwum z programem do instalacji. Archiwum te powinno być wcześniej wypakowane pod tą ścieżkę z zasobów.
Skrypt tworzący instalator
Najpierw należy po sobie wyczyścić. Następnie powinnyśmy utworzyć dowiązanie symboliczne pliku z licencją do katalogu ze skryptem tworzącym instalator. Następnie tworzymy dwa puste pliki: tarbal32 i tarbal64. Pierwszy jest dla plików do wypakowania dla architektury x86, a drugi dla plików do wypakowania dla architektury x86_64. Jest to konieczne, gdyż nie będziemy przeprowadzać generowania instalatora dla architektury x86_64 na procesorze x86 - gcc chyba tego nie wspiera, choć muszę sprawdzić. Następnie należy sprawdzić architekturę, i jeżeli jest to x86_64, wykonujemy make clean -C katalog_ze_źródłami_programu, a następnie make -C katlog_ze_źródłami_programu, po czym make install -C katalog_ze_źródłami_programu. Należy pamiętać tak, by skonfigurować make tak, by instalował program do katalogu, z którego wygenerujemy archiwum dla x86_64. Zawsze(niezależnie czy architekturą jest x86_64, czy też x86,) należy wygenerować program dla x86. Robimy to podobnie, jak dla x86_64, lecz przed każdą komendą należy dopisać
CFLAGS="-m32" CXXFLAGS="-m32" linux32
Należy też pamiętać, by przed przystąpieniem do instalacji programu dla x86, skonfigurować make tak, by instalował do katalogu, z którego wygenerujemy tarbal dla architektury x86.
Tworzenie tarbali
Do utworzenia tarbala, należy wykorzystać przełącznik C, wskazując na katalog z binariami, czyli tak:
tar -cf ./plik_wynikowy -C katalog_z_binariami/ .
Przełącznik -C wskazuje na katalog, do którego ma przejść tar przed wygenerowaniem tarbala. Nie należy generować tarbala w taki oto sposób:
tar -cf ./plik_wynikowy katalog_z_binariami/*
Bo wtedy katalog główny archiwum będzie zawierać katalog katalog_z_binariami, a w nim poszczególne pliki. Spowoduje to, że program będzie nam się źle instalować. Należy również dodać kompresję gzip. Przykład poprawnego wywołania polecenia tar przedstawiam poniżej:
tar -czf ./tarbal32 -C tarbal-files-32/ .