Aplikacja oparta na Elektronie. Rejestracja własnego protokołu
Ostatnio wiele osób pyta mnie, jaki sens ma tworzenie aplikacji bazujących na elektronie, gdy mamy PWA? Pomimo coraz większego zainteresowania PWA, Elektron nie traci na popularności, gdyż porównać je do siebie to jak porównać karabin z czołgiem. Podczas gdy PWA dają wrażenie korzystania z natywnej aplikacji, to ciągle działają one tylko w ramach przeglądarki, a aplikacje oparte na Elektronie są za to pełnoprawnymi aplikacjami natywnymi, które mają dostęp do wszystkich API systemowych. Pomimo tego, dzięki połączeniu silnika renderującego libchromiumcontent z projektu Chromium i Node.js, Elektron pozwala tworzyć w stosunkowo prosty sposób nawet zaawansowane, natywne aplikacje cross-platformowe.
Dawid Wojda. CEO-Founder w Code8, Fullstack. Specjalizuje się w JavaScripcie po stronie frontu i serwera. W pogoni za rozwiązaniem problemu potrafi zagalopować się aż do zagadnień niskopoziomowych, jest fanem wizualizacji a specjalizuje się w grafach i sztuce generatywnej, ciągle szuka i eksperymentuje. Mniej zawodowo: aquascaper, zwolennik aktywnego wypoczynku i gór. Zaprasza do śledzenia profilu na Twitterze.
Oczywiście jak mówi stare porzekadło “with great power comes great responsibility”. Jako frontend deweloperzy żyjemy w pewnego rodzaju sandboxie, kontrolowanym przez środowisko hosta (najczęściej przeglądarkę), które ściśle kształtuje ramy działania naszych programów przez udostępniane API. Odpowiedzialność za ich bezpieczeństwo spada na twórców przeglądarek, my za to możemy się skupić na pisaniu lepszych aplikacji.
W Elektronie sami jako twórcy aplikacji zderzamy się z wieloma problemami bezpieczeństwa, w szczególności, gdy ładujemy zewnętrzny kontent i musimy ostrożnie rozważyć, co i gdzie udostępnić. O jednym z problemów tej klasy możesz przeczytać w poniższym artykule na podstawie studium przypadku.
Spis treści
Dlaczego?
Case study: aplikacja, którą pisałem była dość specyficzna. Sama w sobie nie robiła zbyt wiele, mogła ładować strony z zaufanej domeny, wykonać pewne akcje oraz miała kilka modułów ustawień. Moimi ograniczeniami były:
- wybrane podstrony z zaufanej domeny mogły komunikować się z tworzoną aplikacją poprzez wcześniej ustalone API, na które miałem znikomy wpływ,
- nie mogłem wprowadzać zmian na zaufanej domenie (żadnych nowych podstron i funkcjonalności),
- na domenie zaufanej mogą pojawić się odnośniki otwierające moduły aplikacji,
- całość zamknięta w sieci korporacyjnej.
Pierwsze pomysły na rozwiązanie powyższych problemów:
- udostępnić globalne API widoczne pod window.xxx dla zaufanej domeny z aplikacji elektronowej,
- komunikacja przez API i otwieranie okien modalnych z formularzami dla ustawień?
Dalej zmiany dotyczyły głównie punktu drugiego, ponieważ przez brak wpływu na zaufaną domenę integracja z dodatkowym API mogłaby być problematyczna, tak samo jak wyświetlanie modułów aplikacji w oknach modalnych, ponieważ wymagałoby to dołożenia webview jako warstwy wyświetlającej zaufaną domenę, zaś w warstwie wyżej zaimplementowany byłby interfejs aplikacji.
Aby uprościć trochę powyższy flow wymyśliłem sobie customowy protokół, który byłby obsługiwany przez moją aplikację, aby ładować własne moduły. Sprowadziłoby to również integrację z zaufaną domeną do linku z odpowiednim adresem, np.:
html <a href=”electron-settings://index”>Test link</a>
Dzięki temu nie musiałbym także robić kilku warstw aplikacji, aby obsługiwać oddzielnie interfejs – wszystko ładowałoby się w głównym oknie.
Jak działa protokół w Elektronie?
Jak wiemy, protokół to zbiór pewnych reguł i kroków, aby nawiązać połączenie pomiędzy programami lub, na niższym poziomie, urządzeniami. Jednym z najczęściej używanych i najbardziej rzucających się w oczy protokołów jest HTTP, widoczny zazwyczaj w adresach URL. Sam adres URL jest częścią standardu URI, jego bardzo generyczny schemat można zapisać tak:
scheme:[//authority]path[?query][#fragment]
Gdy tworzymy okno aplikacji, możemy załadować do niego URL np.:
js let browserWindow = new BrowserWindow({}); browserWindow.loadURL(‘http://test.com/’);
W tym miejscu spróbujemy zarejestrować własny protokół o nazwie “electron-settings”, który pojawi się w miejsce HTTP. Co to oznacza? Za każdym razem, gdy ktoś spróbuje załadować adres url “electron-settings://test.com” przejmiemy takie żądanie i zwrócimy dane, które uznamy za stosowne wykorzystując nadal “pod spodem” HTTP do przesyłu danych.
Elektron umożliwia nam rejestrację protokołów na kilka różnych sposobów. Służą do tego metody modułu protocol, który możemy zaimportować z paczki głównej “electron”. Ich schemat wygląda tak: register{Any}Protocol
. Wybrałem registerStreamProtocol, ponieważ jest najbardziej uniwersalną opcją i można nim zwrócić każdy typ pliku w zależności od potrzeb. Ważne jest jednak, aby zwracany obiekt implementował interfejs ReadableStream.
Pozostaje jeszcze jedna kwestia. Do tej pory rozmawialiśmy o stworzeniu protokołu “wewnątrz” aplikacji. Co jednak jeśli ktoś otworzy zaufaną domenę w zwykłej przeglądarce i będzie znajdować się tam link z naszym protokołem?
Tutaj musielibyśmy zarejestrować aplikację w systemie jako domyślny program obsługujący dany protokół. Jest to o tyle problematyczne, że w każdym systemie operacyjnym robi się to inaczej. Nie będę opisywać dokładnie jak, ponieważ to dość szeroki temat. Paczka electron.build w pewien sposób automatyzuje ten proces, niestety w moim przypadku działała wybiórczo. Na dodatek dokumentacja do tej części jest bardzo znikoma przez co musiałem napisać własne skrypty rejestrujące. Niemniej zachęcam do prób, może komuś się uda.
Case study – protokół
Omówimy teraz kod, który w pełni udostępniłem w repozytorium na githubie. Demo jako zaufanej domeny używa strony: https://dawiidio.com/demo/electron/. Jedną z ciekawostek na początku może być ta część:
js protocol.registerSchemesAsPrivileged([ { scheme: PROTOCOL_PREFIX, privileges: { bypassCSP: true, standard: true } } ]);
Aby nasz protokół działał poprawnie, tzn. aby przeglądarka wiedziała jak rozwiązać dla niego relatywne adresy URL lub zarejestrować ServiceWorker-y itp., musimy zarejestrować go jako “standardowy”. Protokołami standardowymi domyślnie są HTTP i HTTPS. Bez rejestracji, protokół zachowywałby się jak file:// i przeglądarka nie miałaby możliwości wykonania dla niego powyższych akcji. Gdy już to zrobimy, możemy np. bez problemu w zawartości ładowanych plików zamieszczać relatywne ścieżki. Załóżmy, że ładujemy stronę electron-settings://test, na której znajduje się tag:
html <img src="meme.jpg" alt="meme" width="300" />
Zostanie on rozwiązany do: electron-settings://meme.jpg i poprawnie zwrócony przez handler, który zaś wygląda tak:
js protocol.registerStreamProtocol( PROTOCOL_PREFIX, (request, callback) => { const headers = {}; const { host, pathname } = new URL(request.url); const isFileRequest = isPathToFile(pathname); // if you want support more file types please use npm package mime-db or mime-types to check them if (isFileRequest) headers["content-type"] = 'image/jpg'; else headers["content-type"] = 'text/html'; const pathToSource = createPathToSource(isFileRequest, { host, pathname }); callback({ statusCode: 200, headers, data: createReadStream(pathToSource) }); }, (error) => { if (error) console.error('Failed to register protocol'); } );
Ok, co tu się dzieje? Jak wspominałem wcześniej, poprzez metodę protocol.registerStreamProtocol rejestrujemy nasz handler, który od teraz będzie przechwytywać każdy request poprzedzony “electron-settings:”.
Na początku rozbijamy adres url z żądaniami na części pierwsze. Na ich podstawie tworzymy flagę i ścieżkę do pliku, a na koniec wywołujemy callback z nagłówkami, statusem i danymi w postaci ReadableStream, które zostaną wysłane do użytkownika.
Na potrzeby dema przyjąłem obsługę tylko dwóch typów plików: html i jpg. Nie obsłużyłem także sytuacji, gdy plik nie zostanie znaleziony, jak widzisz statusCode jest zawsze równy 200. Założyłem również, że moduły będą ładowane bez rozszerzenia, tzn. url electron-settings://test jest równoznaczny z electron-settings://test.html. Wszystkie pliki, które mogą zostać załadowane przez protokół muszą znajdować się w folderze public.
To tyle jeśli chodzi o protokół. Proste, prawda?
Case study – API dla zaufanej domeny
Omówiliśmy protokół, ale pozostała nam jeszcze kwestia udostępnienia API dla zaufanej domeny. Jest to temat o tyle drażliwy, że uderza bardzo o bezpieczeństwo aplikacji opartych na Elektronie. Ale po kolei. Zapewne zauważyłeś tę część:
js window = new BrowserWindow({ webPreferences: { preload: `${__dirname}/preload.js` } });
To ona odpowiada za API udostępnione dla strony, która zostanie załadowana. Dwa słowa wyjaśnienia – skrypt preload zostanie załadowany zawsze przed skryptami dla każdej strony www. Daje to duże możliwości interakcji i jest dobrym miejscem, aby udostępnić publiczne API poprzez np.:
js window.myApi = {...}
Dzięki temu zabiegowi ładowana strona od początku będzie “widzieć” nasze API. Oczywiście w moim przypadku aplikacja komunikowała się ze stroną w zamkniętej sieci, więc ryzyko było jeszcze mniejsze, ale nie należy tego bagatelizować. Komunikację na pewno dałoby się uprościć włączając opcję nodeIntegration dla webPreferences, ale wtedy strona ładowana do aplikacji miałaby dostęp do API node, a co za tym idzie do modułów takich jak fs. Domyślasz się już, że gdyby takie API zostało udostępnione np. złośliwej stronie załadowanej przez przypadek mogłaby ona zyskać dostęp do filesystemu użytkownika i narobić szkód.
Dlatego gdy do aplikacji ładujesz zawartość, co do której nie masz stuprocentowej pewności zawsze wyłączaj nodeIntegration! Jeszcze odnośnie skryptu preload – należy się z nim obchodzić również ostrożnie, ponieważ ma zawsze włączoną integrację z API node. Niemniej jest to o tyle bezpieczniejsze rozwiązanie, że jesteś w stanie udostępnić bardzo specyficzne API, które nie wyrządzi aż takich szkód nawet w przypadku załadowania złośliwej zawartości, ponieważ nie udostępniasz przy tym samego API node-a poza kontekst skryptu. A jeszcze prościej, oznacza to, że API node-a będzie widoczne tylko i wyłącznie dla kodu znajdującego się w pliku preload, i na tym koniec. Nikt z poziomu window nie będzie mógł się dostać do żadnego innego API poza tym, które Ty udostępnisz!
Moje API wygląda bardzo prosto:
js window.electronAPI = { apiVersion: version, setName(value) { electron.ipcRenderer.send('setName', value); }, getName() { return electron.ipcRenderer.sendSync('getName'); } };
Użyłem tutaj magistrali IPC, która pozwala na wysyłanie krótkich wiadomości pomiędzy procesami elektrona. Dalej wiadomości są odbierane w procesie głównym aplikacji:
js let name; // .... ipcMain.on('setName', (ev, val) => { name = val; }); ipcMain.on('getName', (ev) => { ev.returnValue = name; });
Callbacki reagujące na wiadomości z procesu renderera ustawiają lub pobierają tylko jedną zmienną, ale oczywiście mogą robić dużo więcej, a właściwie co tylko sobie wyobrazisz. Zawsze miej jednak na uwadze bezpieczeństwo przyszłych użytkowników Twojej aplikacji!
Podsumowanie
Na przykładzie case study dowiedziałeś się jak stworzyć własny protokół dla aplikacji bazujący na HTTP oraz co musisz jeszcze zrobić, aby aplikacja obsługiwała protokół z poziomu systemu. Wiesz jak udostępnić API, które będzie integrować aplikację z załadowanymi stronami, oraz jak zabezpieczyć je przed niepożądanym rozszerzeniem dostępu – dobrej zabawy!
Zdjęcie główne artykułu pochodzi z pexels.com.