W odróżnieniu od powszechnej architektury „klient-serwer”, aplikacje zdecentralizowane charakteryzują się:
Nie ma potrzeby przechowywania bazy danych z loginami i hasłami użytkowników. Informacje dostępowe przechowywane są wyłącznie przez samych użytkowników, a potwierdzenie ich autentyczności następuje na poziomie protokołu.
Nie ma potrzeby korzystania z serwera. Logika aplikacji może być realizowana w sieci blockchain, gdzie możliwe jest przechowywanie wymaganej ilości danych.
Istnieją 2 stosunkowo bezpieczne miejsca przechowywania kluczy użytkownika – portfele sprzętowe i rozszerzenia przeglądarki. Portfele sprzętowe są w większości wyjątkowo bezpieczne, ale trudne w użyciu i wcale nie darmowe, ale rozszerzenia przeglądarki stanowią idealne połączenie bezpieczeństwa i łatwości użytkowania, a także mogą być całkowicie bezpłatne dla użytkowników końcowych.
Biorąc to wszystko pod uwagę, chcieliśmy stworzyć najbezpieczniejsze rozszerzenie, które uprości rozwój zdecentralizowanych aplikacji, udostępniając proste API do pracy z transakcjami i podpisami.
O tym doświadczeniu opowiemy poniżej.
W artykule będzie zawarta instrukcja krok po kroku jak napisać rozszerzenie do przeglądarki, wraz z przykładami kodu i zrzutami ekranu. Cały kod znajdziesz w repozytoria. Każde zatwierdzenie logicznie odpowiada sekcji tego artykułu.
Krótka historia rozszerzeń przeglądarki
Rozszerzenia przeglądarki istnieją już od dłuższego czasu. Pojawiły się w przeglądarce Internet Explorer w 1999 r., w przeglądarce Firefox w 2004 r. Jednak przez bardzo długi czas nie było jednego standardu rozszerzeń.
Można powiedzieć, że pojawił się wraz z rozszerzeniami w czwartej wersji przeglądarki Google Chrome. Oczywiście nie było wtedy specyfikacji, ale to właśnie Chrome API stało się jej podstawą: podbijając większość rynku przeglądarek i mając wbudowany sklep z aplikacjami, Chrome faktycznie wyznaczył standard rozszerzeń przeglądarek.
Mozilla miała swój własny standard, ale widząc popularność rozszerzeń do Chrome, firma zdecydowała się stworzyć kompatybilne API. W 2015 roku z inicjatywy Mozilli w ramach konsorcjum World Wide Web Consortium (W3C) utworzono specjalną grupę, która ma pracować nad specyfikacjami rozszerzeń dla różnych przeglądarek.
Za podstawę przyjęto istniejące rozszerzenia API dla przeglądarki Chrome. Prace prowadzono przy wsparciu firmy Microsoft (Google odmówił udziału w opracowaniu standardu), w wyniku czego pojawił się projekt specyfikacje.
Formalnie specyfikację obsługują Edge, Firefox i Opera (uwaga, Chrome nie znalazł się na tej liście). Ale w rzeczywistości standard jest w dużej mierze kompatybilny z Chrome, ponieważ faktycznie jest napisany w oparciu o jego rozszerzenia. Możesz przeczytać więcej o API WebExtensions tutaj.
Struktura rozszerzenia
Jedynym plikiem wymaganym do rozszerzenia jest manifest (manifest.json). Jest to także „punkt wejścia” do ekspansji.
Manifest
Zgodnie ze specyfikacją plik manifestu jest prawidłowym plikiem JSON. Pełny opis kluczy manifestu z informacją o tym, które klucze są obsługiwane w jakiej przeglądarce można je przeglądać tutaj.
Klucze, których nie ma w specyfikacji, „mogą” zostać zignorowane (zarówno Chrome, jak i Firefox zgłaszają błędy, ale rozszerzenia nadal działają).
I chciałbym zwrócić uwagę na pewne kwestie.
tło — obiekt zawierający następujące pola:
skrypty — tablica skryptów, które zostaną wykonane w kontekście tła (porozmawiamy o tym nieco później);
strona - zamiast skryptów, które będą wykonywane na pustej stronie, możesz określić html z treścią. W takim przypadku pole skryptu zostanie zignorowane i konieczne będzie wstawienie skryptów do strony z treścią;
uporczywy — flaga binarna, jeśli nie zostanie określona, przeglądarka „zabije” proces działający w tle, gdy uzna, że nic nie robi, i w razie potrzeby uruchomi go ponownie. W przeciwnym razie strona zostanie wyładowana dopiero po zamknięciu przeglądarki. Nieobsługiwane w przeglądarce Firefox.
skrypty treści — tablica obiektów, która umożliwia ładowanie różnych skryptów na różne strony internetowe. Każdy obiekt zawiera następujące ważne pola:
zapałki - adres URL wzoru, który określa, czy określony skrypt treści zostanie uwzględniony, czy nie.
js — lista skryptów, które zostaną załadowane do tego meczu;
wyklucz_dopasowania - wyklucza z pola match Adresy URL pasujące do tego pola.
strona_akcja - to tak naprawdę obiekt odpowiedzialny za ikonę wyświetlaną obok paska adresu w przeglądarce i interakcję z nią. Umożliwia także wyświetlenie wyskakującego okna, które jest zdefiniowane przy użyciu własnego kodu HTML, CSS i JS.
domyślne_wyskakujące okienko — ścieżka do pliku HTML z wyskakującym interfejsem, może zawierać CSS i JS.
uprawnienia — tablica do zarządzania prawami rozszerzeń. Istnieją 3 rodzaje uprawnień, które zostały szczegółowo opisane tutaj
zasoby_dostępne_internetowo — zasoby rozszerzeń, o które może poprosić strona internetowa, na przykład obrazy, pliki JS, CSS, HTML.
możliwość podłączenia zewnętrznego — tutaj możesz jednoznacznie określić identyfikatory innych rozszerzeń i domen stron internetowych, z których możesz się połączyć. Domena może być poziomu drugiego lub wyższego. Nie działa w przeglądarce Firefox.
Kontekst wykonania
Rozszerzenie posiada trzy konteksty wykonania kodu, czyli aplikacja składa się z trzech części o różnym poziomie dostępu do API przeglądarki.
Kontekst rozszerzenia
Większość API jest dostępna tutaj. W tym kontekście „żyją”:
Strona w tle — „backendowa” część rozszerzenia. Plik jest określony w manifeście za pomocą klucza „w tle”.
Wyskakująca strona — wyskakująca strona, która pojawia się po kliknięciu ikony rozszerzenia. W manifeście browser_action -> default_popup.
Kontekst ten istnieje niezależnie od okien i kart przeglądarki. Strona w tle istnieje w pojedynczym egzemplarzu i zawsze działa (wyjątkiem jest strona zdarzenia, gdzie skrypt działający w tle jest uruchamiany przez zdarzenie i „umiera” po jego wykonaniu). Wyskakująca strona istnieje, gdy okno wyskakujące jest otwarte, oraz Strona niestandardowa — gdy karta z nim jest otwarta. Z tego kontekstu nie ma dostępu do innych zakładek i ich zawartości.
Kontekst skryptu treści
Plik skryptu treści jest uruchamiany wraz z każdą zakładką przeglądarki. Ma dostęp do części API rozszerzenia oraz do drzewa DOM strony internetowej. Za interakcję ze stroną odpowiadają skrypty treści. Rozszerzenia manipulujące drzewem DOM robią to w skryptach treści - na przykład blokery reklam lub tłumacze. Ponadto skrypt treści może komunikować się ze stroną w standardzie postMessage.
Kontekst strony internetowej
To jest sama strona internetowa. Nie ma to nic wspólnego z rozszerzeniem i nie ma tam dostępu, z wyjątkiem przypadków, gdy domena tej strony nie jest wyraźnie wskazana w manifeście (więcej na ten temat poniżej).
Wymiana wiadomości
Różne części aplikacji muszą wymieniać między sobą wiadomości. Jest do tego API runtime.sendMessage aby wysłać wiadomość background и tabs.sendMessage aby wysłać wiadomość na stronę (skrypt treści, wyskakujące okienko lub stronę internetową, jeśli jest dostępna externally_connectable). Poniżej znajduje się przykład uzyskiwania dostępu do API Chrome.
// Сообщением может быть любой JSON сериализуемый объект
const msg = {a: 'foo', b: 'bar'};
// extensionId можно не указывать, если мы хотим послать сообщение 'своему' расширению (из ui или контент скрипта)
chrome.runtime.sendMessage(extensionId, msg);
// Так выглядит обработчик
chrome.runtime.onMessage.addListener((msg) => console.log(msg))
// Можно слать сообщения вкладкам зная их id
chrome.tabs.sendMessage(tabId, msg)
// Получить к вкладкам и их id можно, например, вот так
chrome.tabs.query(
{currentWindow: true, active : true},
function(tabArray){
tabArray.forEach(tab => console.log(tab.id))
}
)
Aby uzyskać pełną komunikację, możesz tworzyć połączenia za pośrednictwem runtime.connect. W odpowiedzi otrzymamy runtime.Port, do którego, gdy jest otwarty, możesz wysłać dowolną liczbę wiadomości. Po stronie klienta np. contentscript, To wygląda tak:
// Опять же extensionId можно не указывать при коммуникации внутри одного расширения. Подключение можно именовать
const port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
if (msg.question === "Who's there?")
port.postMessage({answer: "Madame"});
else if (msg.question === "Madame who?")
port.postMessage({answer: "Madame... Bovary"});
Serwer lub tło:
// Обработчик для подключения 'своих' вкладок. Контент скриптов, popup или страниц расширения
chrome.runtime.onConnect.addListener(function(port) {
console.assert(port.name === "knockknock");
port.onMessage.addListener(function(msg) {
if (msg.joke === "Knock knock")
port.postMessage({question: "Who's there?"});
else if (msg.answer === "Madame")
port.postMessage({question: "Madame who?"});
else if (msg.answer === "Madame... Bovary")
port.postMessage({question: "I don't get it."});
});
});
// Обработчик для подключения внешних вкладок. Других расширений или веб страниц, которым разрешен доступ в манифесте
chrome.runtime.onConnectExternal.addListener(function(port) {
...
});
Jest też wydarzenie onDisconnect i metoda disconnect.
Schemat zastosowania
Zróbmy rozszerzenie przeglądarki przechowujące klucze prywatne, zapewniające dostęp do informacji publicznych (adres, klucz publiczny komunikuje się ze stroną i umożliwia aplikacjom stron trzecich żądanie podpisu dla transakcji).
Rozwój aplikacji
Nasza aplikacja musi zarówno wchodzić w interakcję z użytkownikiem, jak i udostępniać stronie API umożliwiające wywoływanie metod (np. podpisywanie transakcji). Wystarczy jeden contentscript nie będzie działać, ponieważ ma dostęp tylko do DOM, ale nie do JS strony. Połącz przez runtime.connect nie możemy, bo API jest potrzebne na wszystkich domenach, a w manifeście można wskazać tylko te konkretne. W rezultacie schemat będzie wyglądał następująco:
Będzie inny scenariusz - inpage, który wstrzykniemy na stronę. Będzie działać w swoim kontekście i udostępniać interfejs API do pracy z rozszerzeniem.
początek
Cały kod rozszerzenia przeglądarki jest dostępny pod adresem GitHub. W opisie będą linki do commitów.
Zacznijmy od manifestu:
{
// Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
"name": "Signer",
"description": "Extension demo",
"version": "0.0.1",
"manifest_version": 2,
// Скрипты, которые будут исполнятся в background, их может быть несколько
"background": {
"scripts": ["background.js"]
},
// Какой html использовать для popup
"browser_action": {
"default_title": "My Extension",
"default_popup": "popup.html"
},
// Контент скрипты.
// У нас один объект: для всех url начинающихся с http или https мы запускаем
// contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*"
],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
// Разрешен доступ к localStorage и idle api
"permissions": [
"storage",
// "unlimitedStorage",
//"clipboardWrite",
"idle"
//"activeTab",
//"webRequest",
//"notifications",
//"tabs"
],
// Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
"web_accessible_resources": ["inpage.js"]
}
Utwórz puste pliki tła.js, popup.js, inpage.js i contentscript.js. Dodajemy popup.html - i naszą aplikację można już załadować do przeglądarki Google Chrome i upewnić się, że działa.
Aby to sprawdzić, możesz wziąć kod stąd. Oprócz tego, co zrobiliśmy, link skonfigurował montaż projektu za pomocą pakietu internetowego. Aby dodać aplikację do przeglądarki, w chrome://extensions należy wybrać wczytaj rozpakowane i folder z odpowiednim rozszerzeniem - w naszym przypadku dist.
Teraz nasze rozszerzenie jest zainstalowane i działa. Narzędzia programistyczne można uruchamiać w różnych kontekstach w następujący sposób:
wyskakujące okienko ->
Dostęp do konsoli skryptów treści odbywa się poprzez konsolę samej strony, na której jest ona uruchamiana.
Wymiana wiadomości
Musimy więc ustalić dwa kanały komunikacji: inpage <-> tło i wyskakujące okienko <-> tło. Można oczywiście po prostu wysyłać wiadomości na port i wymyślać własny protokół, ale ja wolę podejście, które widziałem w projekcie open source metamask.
Jest to rozszerzenie przeglądarki umożliwiające pracę z siecią Ethereum. W nim różne części aplikacji komunikują się poprzez RPC z wykorzystaniem biblioteki dnode. Pozwala dość szybko i wygodnie zorganizować wymianę, jeśli zapewnisz jej strumień nodejs jako transport (czyli obiekt implementujący ten sam interfejs):
import Dnode from "dnode/browser";
// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным
// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)
// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне
// Выведет в консоль world
dnodeClient.once('remote', remote => {
remote.hello(((err, value) => console.log(value)))
})
Teraz utworzymy klasę aplikacji. Utworzy obiekty API dla wyskakującego okienka i strony internetowej oraz utworzy dla nich dnode:
import Dnode from 'dnode/browser';
export class SignerApp {
// Возвращает объект API для ui
popupApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Возвращает объет API для страницы
pageApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Подключает popup ui
connectPopup(connectionStream){
const api = this.popupApi();
const dnode = Dnode(api);
connectionStream.pipe(dnode).pipe(connectionStream);
dnode.on('remote', (remote) => {
console.log(remote)
})
}
// Подключает страницу
connectPage(connectionStream, origin){
const api = this.popupApi();
const dnode = Dnode(api);
connectionStream.pipe(dnode).pipe(connectionStream);
dnode.on('remote', (remote) => {
console.log(origin);
console.log(remote)
})
}
}
Tutaj i poniżej zamiast globalnego obiektu Chrome używamy rozszerzeniaApi, które uzyskuje dostęp do Chrome w przeglądarce Google i przeglądarki w innych. Odbywa się to w celu zapewnienia zgodności z różnymi przeglądarkami, ale na potrzeby tego artykułu można po prostu użyć „chrome.runtime.connect”.
Utwórzmy instancję aplikacji w skrypcie działającym w tle:
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
const app = new SignerApp();
// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
// При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
if (processName === 'contentscript'){
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
}else{
app.connectPopup(portStream)
}
}
Ponieważ dnode współpracuje ze strumieniami, a my otrzymujemy port, potrzebna jest klasa adaptera. Robi się to za pomocą biblioteki readable-stream, która implementuje strumienie nodejs w przeglądarce:
Utwórzmy teraz połączenie w interfejsie użytkownika:
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import Dnode from 'dnode/browser';
const DEV_MODE = process.env.NODE_ENV !== 'production';
setupUi().catch(console.error);
async function setupUi(){
// Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем dnode
const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
const connectionStream = new PortStream(backgroundPort);
const dnode = Dnode();
connectionStream.pipe(dnode).pipe(connectionStream);
const background = await new Promise(resolve => {
dnode.once('remote', api => {
resolve(api)
})
});
// Делаем объект API доступным из консоли
if (DEV_MODE){
global.background = background;
}
}
Następnie tworzymy połączenie w skrypcie treści:
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import PostMessageStream from 'post-message-stream';
setupConnection();
injectScript();
function setupConnection(){
const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
const backgroundStream = new PortStream(backgroundPort);
const pageStream = new PostMessageStream({
name: 'content',
target: 'page',
});
pageStream.pipe(backgroundStream).pipe(pageStream);
}
function injectScript(){
try {
// inject in-page script
let script = document.createElement('script');
script.src = extensionApi.extension.getURL('inpage.js');
const container = document.head || document.documentElement;
container.insertBefore(script, container.children[0]);
script.onload = () => script.remove();
} catch (e) {
console.error('Injection failed.', e);
}
}
Ponieważ potrzebujemy API nie w skrypcie treści, ale bezpośrednio na stronie, robimy dwie rzeczy:
Tworzymy dwa strumienie. Jeden - w stronę strony, na górze wpisuWiadomość. W tym celu używamy tego ten pakiet od twórców metamaski. Drugi strumień jest przesyłany w tle nad portem, z którego otrzymano runtime.connect. Kupmy je. Teraz strona będzie miała strumień w tle.
Wstrzyknij skrypt do DOM. Pobierz skrypt (dostęp do niego był dozwolony w manifeście) i utwórz tag script z zawartością w środku:
import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
setupConnection();
injectScript();
function setupConnection(){
// Стрим к бекграунду
const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
const backgroundStream = new PortStream(backgroundPort);
// Стрим к странице
const pageStream = new PostMessageStream({
name: 'content',
target: 'page',
});
pageStream.pipe(backgroundStream).pipe(pageStream);
}
function injectScript(){
try {
// inject in-page script
let script = document.createElement('script');
script.src = extensionApi.extension.getURL('inpage.js');
const container = document.head || document.documentElement;
container.insertBefore(script, container.children[0]);
script.onload = () => script.remove();
} catch (e) {
console.error('Injection failed.', e);
}
}
Teraz tworzymy obiekt API na stronie i ustawiamy go na globalny:
import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';
setupInpageApi().catch(console.error);
async function setupInpageApi() {
// Стрим к контентскрипту
const connectionStream = new PostMessageStream({
name: 'page',
target: 'content',
});
const dnode = Dnode();
connectionStream.pipe(dnode).pipe(connectionStream);
// Получаем объект API
const pageApi = await new Promise(resolve => {
dnode.once('remote', api => {
resolve(api)
})
});
// Доступ через window
global.SignerApp = pageApi;
}
Puste API i pochodzenie. Po stronie strony możemy wywołać funkcję hello w następujący sposób:
Praca z funkcjami wywołania zwrotnego we współczesnym JS to złe maniery, więc napiszmy małego pomocnika, który stworzy dnode, który pozwoli ci przekazać obiekt API do narzędzi.
Pobieranie obiektu ze pilota w następujący sposób:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// С помощью утилит меняем все callback на promise
resolve(transformMethods(cbToPromise, remoteApi))
})
});
A wywołanie funkcji zwraca obietnicę:
Dostępna wersja z funkcjami asynchronicznymi tutaj.
Ogólnie rzecz biorąc, podejście RPC i strumieniowe wydaje się dość elastyczne: możemy użyć multipleksowania parowego i stworzyć kilka różnych interfejsów API do różnych zadań. W zasadzie dnode można używać wszędzie, najważniejsze jest zawinięcie transportu w postaci strumienia nodejs.
Alternatywą jest format JSON, który implementuje protokół JSON RPC 2. Działa on jednak z określonymi transportami (TCP i HTTP(S)), co w naszym przypadku nie ma zastosowania.
Stan wewnętrzny i pamięć lokalna
Będziemy musieli przechowywać stan wewnętrzny aplikacji - przynajmniej klucze podpisujące. W wyskakującym API możemy w dość prosty sposób dodać stan do aplikacji oraz metody jego zmiany:
W tle zawiniemy wszystko w funkcję i zapiszemy obiekt aplikacji w oknie, abyśmy mogli z nim pracować z konsoli:
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
const DEV_MODE = process.env.NODE_ENV !== 'production';
setupApp();
function setupApp() {
const app = new SignerApp();
if (DEV_MODE) {
global.app = app;
}
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
if (processName === 'contentscript') {
const origin = remotePort.sender.url;
app.connectPage(portStream, origin)
} else {
app.connectPopup(portStream)
}
}
}
Dodajmy kilka kluczy z konsoli interfejsu użytkownika i zobaczmy, co stanie się ze stanem:
Stan musi być trwały, aby klucze nie zostały utracone podczas ponownego uruchamiania.
Będziemy go przechowywać w localStorage, nadpisując przy każdej zmianie. Później dostęp do niego będzie niezbędny również dla interfejsu użytkownika, a ja też chciałbym subskrybować zmiany. Na tej podstawie wygodnie będzie utworzyć obserwowalny magazyn i subskrybować jego zmiany.
Będziemy korzystać z biblioteki mobx (https://github.com/mobxjs/mobx). Wybór padł na niego, ponieważ nie musiałem z nim pracować, ale bardzo chciałem go przestudiować.
Dodajmy inicjalizację stanu początkowego i sprawmy, aby sklep był obserwowalny:
import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
export class SignerApp {
constructor(initState = {}) {
// Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
this.store = observable.object({
keys: initState.keys || [],
});
}
// Методы, которые меняют observable принято оборачивать декоратором
@action
addKey(key) {
this.store.keys.push(key)
}
@action
removeKey(index) {
this.store.keys.splice(index, 1)
}
...
}
„Pod maską” mobx zastąpił wszystkie pola sklepu serwerem proxy i przechwytuje wszystkie połączenia do nich. Będzie można subskrybować te wiadomości.
Poniżej często będę używał określenia „przy zmianie”, choć nie jest to do końca poprawne. Mobx śledzi dostęp do pól. Używane są metody pobierające i ustawiające obiekty proxy tworzone przez bibliotekę.
Dekoratory akcji służą dwóm celom:
W trybie ścisłym z flagąforceActions mobx zabrania bezpośredniej zmiany stanu. Za dobrą praktykę uważa się pracę w ściśle określonych warunkach.
Nawet jeśli funkcja zmieni stan kilkukrotnie – na przykład zmienimy kilka pól w kilku linijkach kodu – obserwatorzy zostaną powiadomieni dopiero wtedy, gdy funkcja się zakończy. Jest to szczególnie ważne w przypadku frontendu, gdzie niepotrzebne aktualizacje stanu prowadzą do niepotrzebnego renderowania elementów. W naszym przypadku ani to pierwsze, ani drugie nie jest szczególnie istotne, ale będziemy kierować się najlepszymi praktykami. Zwyczajowo do wszystkich funkcji zmieniających stan obserwowanych pól dołącza się dekoratory.
W tle dodamy inicjalizację i zapis stanu w localStorage:
import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";
const DEV_MODE = process.env.NODE_ENV !== 'production';
setupApp();
function setupApp() {
const initState = loadState();
const app = new SignerApp(initState);
if (DEV_MODE) {
global.app = app;
}
// Setup state persistence
// Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
const localStorageReaction = reaction(
() => toJS(app.store), // Функция-селектор данных
saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
);
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
if (processName === 'contentscript') {
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
} else {
app.connectPopup(portStream)
}
}
}
Interesująca jest tutaj funkcja reakcji. Ma dwa argumenty:
Selektor danych.
Procedura obsługi, która będzie wywoływana z tymi danymi przy każdej zmianie.
W przeciwieństwie do redux, gdzie jawnie otrzymujemy stan jako argument, mobx pamięta, do których obserwacji mamy dostęp wewnątrz selektora i wywołuje procedurę obsługi tylko wtedy, gdy się zmieniają.
Ważne jest, aby dokładnie zrozumieć, w jaki sposób mobx decyduje, które obserwable subskrybujemy. Gdybym napisał selektor w kodzie takim jak ten() => app.store, wówczas reakcja nigdy nie zostanie wywołana, ponieważ sama pamięć nie jest obserwowalna, a jedynie jej pola.
Jeśli napisałem to w ten sposób () => app.store.keys, to znowu nic by się nie stało, ponieważ podczas dodawania/usuwania elementów tablicy odniesienie do niego nie ulegnie zmianie.
Mobx po raz pierwszy pełni rolę selektora i śledzi tylko te obserwowalne, do których uzyskaliśmy dostęp. Odbywa się to za pomocą modułów pobierających proxy. Dlatego zastosowano tutaj wbudowaną funkcję toJS. Zwraca nowy obiekt ze wszystkimi proxy zastąpionymi oryginalnymi polami. Podczas wykonywania odczytuje wszystkie pola obiektu - stąd uruchamiane są gettery.
W wyskakującej konsoli ponownie dodamy kilka kluczy. Tym razem trafiły również do localStorage:
Po ponownym załadowaniu strony tła informacje pozostają na swoim miejscu.
Można wyświetlić cały kod aplikacji do tego momentu tutaj.
Bezpieczne przechowywanie kluczy prywatnych
Przechowywanie kluczy prywatnych w postaci zwykłego tekstu jest niebezpieczne: zawsze istnieje ryzyko, że zostaniesz zhakowany, uzyskasz dostęp do swojego komputera itp. Dlatego w localStorage będziemy przechowywać klucze w formie zaszyfrowanej hasłem.
Dla większego bezpieczeństwa dodamy do aplikacji stan zablokowania, w którym nie będzie w ogóle dostępu do kluczy. Automatycznie przeniesiemy rozszerzenie do stanu zablokowanego z powodu przekroczenia limitu czasu.
Mobx pozwala na przechowywanie jedynie minimalnego zestawu danych, a reszta jest automatycznie na jego podstawie obliczana. Są to tak zwane właściwości obliczone. Można je porównać do widoków w bazach danych:
import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";
export class SignerApp {
constructor(initState = {}) {
this.store = observable.object({
// Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
password: null,
vault: initState.vault,
// Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
get locked(){
return this.password == null
},
get keys(){
return this.locked ?
undefined :
SignerApp._decryptVault(this.vault, this.password)
},
get initialized(){
return this.vault !== undefined
}
})
}
// Инициализация пустого хранилища новым паролем
@action
initVault(password){
this.store.vault = SignerApp._encryptVault([], password)
}
@action
lock() {
this.store.password = null
}
@action
unlock(password) {
this._checkPassword(password);
this.store.password = password
}
@action
addKey(key) {
this._checkLocked();
this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
}
@action
removeKey(index) {
this._checkLocked();
this.store.vault = SignerApp._encryptVault([
...this.store.keys.slice(0, index),
...this.store.keys.slice(index + 1)
],
this.store.password
)
}
... // код подключения и api
// private
_checkPassword(password) {
SignerApp._decryptVault(this.store.vault, password);
}
_checkLocked() {
if (this.store.locked){
throw new Error('App is locked')
}
}
// Методы для шифровки/дешифровки хранилища
static _encryptVault(obj, pass){
const jsonString = JSON.stringify(obj)
return encrypt(jsonString, pass)
}
static _decryptVault(str, pass){
if (str === undefined){
throw new Error('Vault not initialized')
}
try {
const jsonString = decrypt(str, pass)
return JSON.parse(jsonString)
}catch (e) {
throw new Error('Wrong password')
}
}
}
Teraz przechowujemy tylko zaszyfrowane klucze i hasło. Wszystko inne jest obliczane. Przeniesienie do stanu zablokowanego wykonujemy usuwając hasło ze stanu. Publiczny interfejs API ma teraz metodę inicjowania magazynu.
import CryptoJS from 'crypto-js'
// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
while (rounds-- > 0){
pass = CryptoJS.SHA256(pass).toString()
}
return pass
}
export function encrypt(str, pass){
const strongPass = strengthenPassword(pass);
return CryptoJS.AES.encrypt(str, strongPass).toString()
}
export function decrypt(str, pass){
const strongPass = strengthenPassword(pass)
const decrypted = CryptoJS.AES.decrypt(str, strongPass);
return decrypted.toString(CryptoJS.enc.Utf8)
}
Przeglądarka posiada bezczynne API poprzez które można subskrybować zdarzenie - zmiany stanu. Stan, odpowiednio, może być idle, active и locked. W przypadku bezczynności można ustawić limit czasu, a zablokowanie jest ustawiane, gdy sam system operacyjny jest zablokowany. Zmienimy także selektor zapisu w localStorage:
import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";
const DEV_MODE = process.env.NODE_ENV !== 'production';
const IDLE_INTERVAL = 30;
setupApp();
function setupApp() {
const initState = loadState();
const app = new SignerApp(initState);
if (DEV_MODE) {
global.app = app;
}
// Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
reaction(
() => ({
vault: app.store.vault
}),
saveState
);
// Таймаут бездействия, когда сработает событие
extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
// Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
extensionApi.idle.onStateChanged.addListener(state => {
if (['locked', 'idle'].indexOf(state) > -1) {
app.lock()
}
});
// Connect to other contexts
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
if (processName === 'contentscript') {
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
} else {
app.connectPopup(portStream)
}
}
}
Dochodzimy więc do najważniejszej rzeczy: tworzenia i podpisywania transakcji na blockchainie. Będziemy używać blockchainu i biblioteki WAVES transakcje falowe.
Najpierw dodajmy do stanu tablicę wiadomości, które należy podpisać, a następnie dodajmy metody dodania nowej wiadomości, potwierdzenia podpisu i odmowy:
import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";
export class SignerApp {
...
@action
newMessage(data, origin) {
// Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
const message = observable.object({
id: uuid(), // Идентификатор, используюю uuid
origin, // Origin будем впоследствии показывать в интерфейсе
data, //
status: 'new', // Статусов будет четыре: new, signed, rejected и failed
timestamp: Date.now()
});
console.log(`new message: ${JSON.stringify(message, null, 2)}`);
this.store.messages.push(message);
// Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
return new Promise((resolve, reject) => {
reaction(
() => message.status, //Будем обсервить статус сообщеня
(status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
switch (status) {
case 'signed':
resolve(message.data);
break;
case 'rejected':
reject(new Error('User rejected message'));
break;
case 'failed':
reject(new Error(message.err.message));
break;
default:
return
}
reaction.dispose()
}
)
})
}
@action
approve(id, keyIndex = 0) {
const message = this.store.messages.find(msg => msg.id === id);
if (message == null) throw new Error(`No msg with id:${id}`);
try {
message.data = signTx(message.data, this.store.keys[keyIndex]);
message.status = 'signed'
} catch (e) {
message.err = {
stack: e.stack,
message: e.message
};
message.status = 'failed'
throw e
}
}
@action
reject(id) {
const message = this.store.messages.find(msg => msg.id === id);
if (message == null) throw new Error(`No msg with id:${id}`);
message.status = 'rejected'
}
...
}
Gdy otrzymamy nową wiadomość, dodajemy do niej metadane, robimy to observable i dodaj do store.messages.
Jeśli tego nie zrobisz observable ręcznie, wówczas mobx zrobi to sam podczas dodawania wiadomości do tablicy. Stworzy to jednak nowy obiekt, do którego nie będziemy mieli odniesienia, ale będziemy go potrzebować w kolejnym kroku.
Następnie zwracamy obietnicę, która zostanie rozpatrzona, gdy zmieni się status wiadomości. Stan jest monitorowany poprzez reakcję, która „zabije się” w przypadku zmiany statusu.
Kod metody approve и reject bardzo proste: po prostu zmieniamy status wiadomości, w razie potrzeby po jej podpisaniu.
Umieszczamy opcję Zatwierdź i odrzuć w interfejsie API interfejsu użytkownika, nową wiadomość w interfejsie API strony:
Interfejs wymaga dostępu do stanu aplikacji. Po stronie interfejsu użytkownika to zrobimy observable state i dodaj do API funkcję, która zmieni ten stan. Dodajmy observable do obiektu API otrzymanego z tła:
import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";
const DEV_MODE = process.env.NODE_ENV !== 'production';
setupUi().catch(console.error);
async function setupUi() {
// Подключаемся к порту, создаем из него стрим
const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
const connectionStream = new PortStream(backgroundPort);
// Создаем пустой observable для состояния background'a
let backgroundState = observable.object({});
const api = {
//Отдаем бекграунду функцию, которая будет обновлять observable
updateState: async state => {
Object.assign(backgroundState, state)
}
};
// Делаем RPC объект
const dnode = setupDnode(connectionStream, api);
const background = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
resolve(transformMethods(cbToPromise, remoteApi))
})
});
// Добавляем в background observable со стейтом
background.state = backgroundState;
if (DEV_MODE) {
global.background = background;
}
// Запуск интерфейса
await initApp(background)
}
Na koniec zaczynamy renderować interfejs aplikacji. To jest aplikacja reagująca. Obiekt tła jest po prostu przekazywany za pomocą rekwizytów. Poprawne byłoby oczywiście utworzenie osobnej usługi dla metod i magazynu dla stanu, ale na potrzeby tego artykułu wystarczy:
import {render} from 'react-dom'
import App from './App'
import React from "react";
// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
render(
<App background={background}/>,
document.getElementById('app-content')
);
}
Dzięki mobx bardzo łatwo jest rozpocząć renderowanie w przypadku zmiany danych. Po prostu zawieszamy dekorator obserwatora na opakowaniu reakcja mobx na komponencie, a render zostanie automatycznie wywołany, gdy jakiekolwiek obserwable, do których odwołuje się komponent, ulegną zmianie. Nie potrzebujesz żadnych mapStateToProps ani nie łączysz się jak w Redux. Wszystko działa od razu po wyjęciu z pudełka:
import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'
@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {
// Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
// и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
render() {
const {keys, messages, initialized, locked} = this.props.background.state;
const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;
return <Fragment>
{!initialized
?
<Init onInit={initVault}/>
:
locked
?
<Unlock onUnlock={unlock}/>
:
messages.length > 0
?
<Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
:
<Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
}
<div>
{!locked && <button onClick={() => lock()}>Lock App</button>}
{initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
</div>
</Fragment>
}
}
Teraz w klasie aplikacji musisz utworzyć selektor stanu dla interfejsu użytkownika i powiadomić interfejs użytkownika o jego zmianach. Aby to zrobić, dodajmy metodę getState и reactionpowołanie remote.updateState:
import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";
export class SignerApp {
...
// public
getState() {
return {
keys: this.store.keys,
messages: this.store.newMessages,
initialized: this.store.initialized,
locked: this.store.locked
}
}
...
//
connectPopup(connectionStream) {
const api = this.popupApi();
const dnode = setupDnode(connectionStream, api);
dnode.once('remote', (remote) => {
// Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
const updateStateReaction = reaction(
() => this.getState(),
(state) => remote.updateState(state),
// Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
// Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
{fireImmediately: true, delay: 500}
);
// Удалим подписку при отключении клиента
dnode.once('end', () => updateStateReaction.dispose())
})
}
...
}
Podczas odbierania przedmiotu remote jest tworzony reaction aby zmienić stan wywołujący funkcję po stronie interfejsu użytkownika.
Ostatnim akcentem jest dodanie wyświetlania nowych wiadomości na ikonie rozszerzenia:
function setupApp() {
...
// Reaction на выставление текста беджа.
reaction(
() => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
text => extensionApi.browserAction.setBadgeText({text}),
{fireImmediately: true}
);
...
}
Zatem aplikacja jest gotowa. Strony internetowe mogą wymagać podpisu w przypadku transakcji:
Jeśli przeczytałeś artykuł do końca, ale nadal masz pytania, możesz je zadać pod adresem repozytoria z rozszerzeniem. Znajdziesz tam również zatwierdzenia dla każdego wyznaczonego kroku.
A jeśli jesteś zainteresowany sprawdzeniem kodu rzeczywistego rozszerzenia, możesz to znaleźć tutaj.