
W przeciwieństwie do powszechnie stosowanej architektury „klient-serwer”, aplikacje zdecentralizowane charakteryzują się:
- Brakiem potrzeby przechowywania bazy danych z loginy i hasłami użytkowników. Informacje dostępu przechowywane są wyłącznie u samych użytkowników, a potwierdzenie ich autentyczności odbywa się na poziomie protokołu.
- Brakiem potrzeby korzystania z serwera. Logika aplikacji może być realizowana w sieci blockchain, gdzie możliwe jest także przechowywanie niezbędnej ilości danych.
Istnieją 2 stosunkowo bezpieczne miejsca do przechowywania kluczy użytkowników — portfele sprzętowe i rozszerzenia przeglądarki. Portfele sprzętowe są w większości maksymalnie bezpieczne, jednak trudne w użyciu i z reguły kosztowne, a rozszerzenia przeglądarki stanowią idealne połączenie bezpieczeństwa i prostoty użytkowania i mogą być zupełnie darmowe dla końcowych użytkowników.
Mając to wszystko na uwadze, postanowiliśmy stworzyć maksymalnie bezpieczne rozszerzenie, które upraszcza rozwój aplikacji zdecentralizowanych, oferując prosty API do obsługi transakcji i podpisów.
O tym doświadczeniu opowiemy poniżej.
W artykule znajdziesz krok po kroku instrukcję, jak napisać rozszerzenie przeglądarki, z przykładami kodu i zrzutami ekranu. Cały kod znajdziesz w . Każde zatwierdzenie logicznie odpowiada sekcji tego artykułu.
Krótka historia rozszerzeń przeglądarek
Rozszerzenia przeglądarek istnieją już od dłuższego czasu. W Internet Explorer pojawiły się już w 1999 roku, w Firefox – w 2004 roku. Niemniej jednak przez długi czas brakowało jednego standardu dla rozszerzeń.
Można powiedzieć, że standard pojawił się razem z rozszerzeniami w czwartej wersji Google Chrome. Oczywiście, wtedy nie istniała żadna specyfikacja, ale to API Chrome stało się jej podstawą: zdobywając dużą część rynku przeglądarek i mając wbudowany sklep z aplikacjami, Chrome de facto ustanowił standard dla rozszerzeń przeglądarek.
Mozilla miała swoją specyfikację, ale widząc popularność rozszerzeń dla Chrome, firma postanowiła stworzyć zgodne API. W 2015 roku na inicjatywę Mozilli w ramach World Wide Web Consortium (W3C) powstała specjalna grupa zajmująca się pracą nad specyfikacjami rozszerzeń wielobrowersowych.
Podstawą był już istniejący API rozszerzeń dla Chrome. Prace były prowadzone przy wsparciu Microsoftu (Google odmówiło uczestnictwa w opracowywaniu standardu), a w rezultacie powstał szkic .
Formalnie specyfikację wspierają Edge, Firefox i Opera (zauważ, że w tej liście brakuje Chrome). Ale w rzeczywistości standard w dużej mierze jest zgodny również z Chrome, ponieważ został faktycznie napisany na podstawie jego rozszerzeń. Więcej informacji na temat WebExtensions API można przeczytać .
Struktura rozszerzenia
Jedynym plikiem, który jest niezbędny dla rozszerzenia, jest manifest (manifest.json). To on jest „punktem wejścia” do rozszerzenia.
Manifest
Zgodnie ze specyfikacją plik manifestu jest ważnym plikiem JSON. Pełny opis kluczów manifestu z informacjami o tym, które klucze są wspierane w jakiej przeglądarce, można zobaczyć .
Klucze, których nie ma w specyfikacji, „mogą” być zignorowane (zarówno Chrome, jak i Firefox zgłaszają błędy, ale rozszerzenia wciąż działają).
Chciałbym zwrócić uwagę na kilka kwestii.
- background — obiekt, który zawiera następujące pola:
- scripts — tablica skryptów, które będą wykonywane w kontekście background (błędy możemy omówić później);
- (strony, na które następuje przekierowanie z reklamowych banerów); — zamiast skryptów, które będą wykonywane na pustej stronie, można określić HTML z treścią. W takim przypadku pole script będzie zignorowane, a skrypty trzeba będzie wstawić do strony z treścią;
- utrzymujące się — binarny flaga, jeśli nie jest określona, przeglądarka będzie 'zabijać' proces background, kiedy uzna, że nic nie robi, aby uruchomić go ponownie w razie potrzeby. W przeciwnym razie strona będzie wygaszana tylko przy zamknięciu przeglądarki. Nie jest wspierane w Firefoxie.
- content_scripts — tablica obiektów, która pozwala ładować różne skrypty do różnych stron internetowych. Każdy obiekt zawiera następujące ważne pola:
- matches — , na podstawie którego określa się, czy konkretny skrypt treści zostanie włączony, czy nie.
- js — lista skryptów, które będą załadowane w danym meczu;
- exclude_matches — wyklucza z pola
dopasowanieURL, które spełniają to pole.
- page_action — właściwie jest obiektem odpowiedzialnym za ikonę, która jest wyświetlana obok paska adresu w przeglądarce, oraz interakcję z nią. Pozwala również na wyświetlanie okna popup, które jest definiowane za pomocą własnego HTML, CSS i JS.
- default_popup — ścieżka do pliku HTML z interfejsem popup, może zawierać CSS i JS.
- permissions — tablica do zarządzania uprawnieniami rozszerzenia. Istnieją 3 typy uprawnień, które są szczegółowo opisane
- web_accessible_resources — zasoby rozszerzenia, które mogą być żądane przez stronę internetową, takie jak obrazy, pliki JS, CSS, HTML.
- externally_connectable — tutaj można jawnie wskazać ID innych rozszerzeń oraz domeny stron internetowych, z których można się łączyć. Domena może być drugiego poziomu i wyżej. Nie działa w Firefoxie.
Kontekst wykonania
Rozszerzenie ma trzy konteksty wykonania kodu, co oznacza, że aplikacja składa się z trzech części z różnym poziomem dostępu do API przeglądarki.
Kontekst rozszerzenia
Tutaj dostępna jest większość API. W tym kontekście „żyją”:
- Strona w tle — część „backendowa” rozszerzenia. Plik jest podawany w manifeście pod kluczem „background”.
- Strona popup — strona popup, która pojawia się po kliknięciu na ikonę rozszerzenia. W manifeście
browser_action->default_popup. - Strona niestandardowa — strona rozszerzenia, „żyjąca” w oddzielnej karcie o wyglądzie
chrome-extension:///customPage.html.
Ten kontekst istnieje niezależnie od okien i kart przeglądarki. Strona w tle istnieje w jednym egzemplarzu i działa zawsze (wyjątek — strona zdarzenia, kiedy skrypt w tle jest uruchamiany w odpowiedzi na zdarzenie i „umiera” po jego wykonaniu). Strona popup istnieje, gdy otwarte jest okno popup, a Strona niestandardowa — dopóki karta z nim jest otwarta. Nie ma dostępu do innych kart i ich zawartości z tego kontekstu.
Kontekst skryptu treści
Plik skryptu treści uruchamia się razem z każdą kartą przeglądarki. Ma dostęp do części API rozszerzenia i do drzewa DOM strony internetowej. To właśnie skrypty treści odpowiadają za interakcję ze stroną. Rozszerzenia, które manipulują drzewem DOM, robią to za pomocą skryptów treści – na przykład blokery reklam lub tłumacze. Także skrypt treści może komunikować się ze stroną przez standardowe postMessage.
Kontekst strony internetowej
To sama strona internetowa. Nie ma ona żadnego związku z rozszerzeniem i nie ma do niego dostępu, chyba że w manifeście wyraźnie nie wskazano domeny tej strony (o tym więcej poniżej).
Wymiana wiadomości
Różne części aplikacji muszą wymieniać się wiadomościami między sobą. W tym celu istnieje API runtime.sendMessage do wysyłania wiadomości background i tabs.sendMessage do wysyłania wiadomości do strony (skryptu treści, popup’a lub strony internetowej, jeśli externally_connectable). Poniżej znajduje się przykład przywołania 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 zapewnić pełnoprawną komunikację, można tworzyć połączenia przez runtime.connect. W odpowiedzi otrzymamy runtime.Port, do którego, dopóki jest otwarty, można wysyłać dowolną liczbę wiadomości. Po stronie klienta, na przykład, contentscript, wygląda to 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 background:
// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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 również zdarzenie onDisconnect i metodę rozłączenie.
Schemat aplikacji
Stwórzmy rozszerzenie przeglądarki, które przechowuje klucze prywatne, udostępnia dostęp do publicznych informacji (adres, publiczny klucz komunikuje się ze stroną i umożliwia aplikacjom zewnętrznym żądanie podpisania transakcji.
Rozwój aplikacji
Nasza aplikacja powinna zarówno wchodzić w interakcje z użytkownikiem, jak i udostępniać stronie API do wywoływania metod (na przykład do podpisywania transakcji). Nie możemy ograniczyć się wyłącznie do contentscript , ponieważ ma on dostęp tylko do DOM, a nie do JS strony. Nie możemy się połączyć przez runtime.connect , ponieważ API jest potrzebne na wszystkich domenach, a w manifeście można podać tylko konkretne. Ostatecznie schemat będzie wyglądał tak:

Będzie jeszcze jeden skrypt — inpage, który wstrzyknę w stronę. Będzie on wykonywany w jej kontekście i udostępni API do pracy z rozszerzeniem.
Początek
Cały kod rozszerzenia przeglądarki jest dostępny na . W opisie będą linki do commitów.
Zacznijmy od manifestu:
{
// Nazwa i opis, wersja. Wszystko to będzie widoczne w przeglądarce pod adresem chrome://extensions/?id=<id rozszerzenia>
"name": "Signer",
"description": "Przykład rozszerzenia",
"version": "0.0.1",
"manifest_version": 2,
// Skrypty, które będą wykonywane w tle, może ich być kilka
"background": {
"scripts": ["background.js"]
},
// Jaki html użyć dla popup
"browser_action": {
"default_title": "Moje Rozszerzenie",
"default_popup": "popup.html"
},
// Skrypty zawartości.
// Mamy jeden obiekt: dla wszystkich URL zaczynających się od http lub https uruchamiamy
// kontekst contenscript ze skryptem contentscript.js. Uruchomimy go natychmiast po otrzymaniu dokumentu dla wszystkich ram.
"content_scripts": [
{
"matches": [
"http:/*/*",
"https:/*/*"
],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
// Dostęp do localStorage i idle api jest dozwolony
"permissions": [
"storage",
// "unlimitedStorage",
//"clipboardWrite",
"idle"
//"activeTab",
//"webRequest",
//"notifications",
//"tabs"
],
// Tutaj wskazuje się zasoby, do których będzie miał dostęp strona internetowa. Można je będzie żądać fetche'm lub zwykłym xhr
"web_accessible_resources": ["inpage.js"]
}Tworzymy puste pliki background.js, popup.js, inpage.js i contentscript.js. Dodajemy popup.html — i nasze aplikacja jest już gotowa do załadowania w Google Chrome, aby upewnić się, że działa.
Aby to zweryfikować, można wykorzystać kod . Oprócz tego, co zrobiliśmy, w linku skonfigurowano budowanie projektu za pomocą webpack. Aby dodać aplikację do przeglądarki, w chrome://extensions należy wybrać opcję load unpacked i wskazać folder z odpowiednim rozszerzeniem — w naszym przypadku dist.

Teraz nasze rozszerzenie jest zainstalowane i działa. Narzędzia deweloperskie dla różnych kontekstów można uruchomić w następujący sposób:
popup ->

Dostęp do konsoli skryptu zawartości uzyskuje się przez konsolę samej strony, na której jest uruchomiony.
Wymiana wiadomości
Zatem musimy ustanowić dwa kanały komunikacji: inpage <-> background i popup <-> background. Oczywiście można po prostu wysyłać wiadomości przez port i wymyślić własny protokół, ale bardziej podoba mi się podejście, które zobaczyłem w otwartym projekcie metamask.
To rozszerzenie przeglądarki do pracy z siecią Ethereum. W nim różne części aplikacji komunikują się przez RPC przy użyciu biblioteki dnode. Umożliwia ona dość szybkie i wygodne zorganizowanie wymiany, jeśli jako transport użyje się strumienia nodejs (mowa o obiekcie, który implementuje ten sam interfejs):
import Dnode from "dnode/browsers";
// W tym przykładzie umówmy się, że klient zdalnie wywołuje funkcje na serwerze, chociaż nic nie stoi na przeszkodzie, aby uczynić to dwukierunkowym
// Serwer
// API, które chcemy udostępnić
const dnode = Dnode({
hello: (cb) => cb(null, "world")
})
// Transport, na którym będzie działać dnode. Dowolny strumień nodejs. W przeglądarce dostępna jest biblioteka 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)
// Klient
const dnodeClient = Dnode() // Wywołanie bez argumentu oznacza, że nie udostępniamy API po drugiej stronie
// Wyświetli w konsoli world
dnodeClient.once('remote', remote => {
remote.hello(((err, value) => console.log(value)))
})Teraz stworzymy klasę aplikacji. Będzie ona tworzyć obiekty API dla popupu i strony internetowej, a także tworzyć dnode dla nich:
import Dnode from 'dnode/browser';
export class SignerApp {
// Zwraca obiekt API dla UI
popupApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Zwraca obiekt API dla strony
pageApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Łączy popup UI
connectPopup(connectionStream){
const api = this.popupApi();
const dnode = Dnode(api);
connectionStream.pipe(dnode).pipe(connectionStream);
dnode.on('remote', (remote) => {
console.log(remote)
})
}
// Łączy stronę
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)
})
}
}W tym miejscu i dalej zamiast globalnego obiektu Chrome używamy extentionApi, który odnosi się do Chrome w przeglądarce od Google oraz do browser w innych. Dzieje się tak dla zapewnienia zgodności między przeglądarkami, jednak w ramach tego artykułu można by użyć po prostu 'chrome.runtime.connect'.
Stworzymy instancję aplikacji w skrypcie background:
import {extensionApi} from './utils/extensionApi';
import {PortStream} from './utils/PortStream';
import {SignerApp} from './SignerApp';
const app = new SignerApp();
// onConnect uruchamia się przy połączeniu 'procesów' (contentscript, popup, lub strona rozszerzenia)
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
// Przy ustalaniu połączenia można podać nazwę, na podstawie której określamy, kto się z nami połączył, contentscript czy UI
if (processName === 'contentscript'){
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
}else{
app.connectPopup(portStream)
}
}Ponieważ dnode działa na strumieniach, a my otrzymujemy port, potrzebna jest klasa-adapter. Została stworzona przy użyciu biblioteki readable-stream, która implementuje strumienie nodejs w przeglądarce:
import {Duplex} from 'readable-stream';
export class PortStream extends Duplex{
constructor(port){
super({objectMode: true});
this._port = port;
port.onMessage.addListener(this._onMessage.bind(this));
port.onDisconnect.addListener(this._onDisconnect.bind(this))
}
_onMessage(msg) {
if (Buffer.isBuffer(msg)) {
delete msg._isBuffer;
const data = new Buffer(msg);
this.push(data)
} else {
this.push(msg)
}
}
_onDisconnect() {
this.destroy()
}
_read(){}
_write(msg, encoding, cb) {
try {
if (Buffer.isBuffer(msg)) {
const data = msg.toJSON();
data._isBuffer = true;
this._port.postMessage(data)
} else {
this._port.postMessage(msg)
}
} catch (err) {
return cb(new Error('PortStream - disconnected'))
}
cb()
}
}Teraz tworzymy połączenie w UI:
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(){
// Podobnie jak w klasie aplikacji tworzymy port, opakowujemy w stream, tworzymy 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)
})
});
// Udostępniamy obiekt API z konsoli
if (DEV_MODE){
global.background = background;
}
}Następnie tworzymy połączenie w skrypcie zawartoś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 {
// wstrzykuj skrypt na stronie
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('Wstrzykiwanie nie powiodło się.', e);
}
}Ponieważ API potrzebujemy nie w skrypcie zawartości, a bezpośrednio na stronie, robimy dwie rzeczy:
- Tworzymy dwa strumienie. Jeden — w kierunku strony, ponad postMessage. Używamy do tego od twórców metamask. Drugi strumień — do tła przez port uzyskany od
runtime.connect. Łączymy je. Teraz strumień będzie dostępny dla strony. - Wstrzykujemy skrypt do DOM. Ładujemy skrypt (dostęp do niego był dozwolony w manifeście) i tworzymy tag
scriptz jego zawartością wewnątrz:
import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
setupConnection();
injectScript();
function setupConnection(){
// Strumień do tła
const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
const backgroundStream = new PortStream(backgroundPort);
// Strumień do strony
const pageStream = new PostMessageStream({
name: 'content',
target: 'page',
});
pageStream.pipe(backgroundStream).pipe(pageStream);
}
function injectScript(){
try {
// wstrzykuj skrypt na stronie
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('Wstrzykiwanie nie powiodło się.', e);
}
}Teraz tworzymy obiekt api w inpage i udostępniamy go globalnie:
import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';
setupInpageApi().catch(console.error);
async function setupInpageApi() {
// Stream do skryptu zawartości
const connectionStream = new PostMessageStream({
name: 'page',
target: 'content',
});
const dnode = Dnode();
connectionStream.pipe(dnode).pipe(connectionStream);
// Otrzymujemy obiekt API
const pageApi = await new Promise(resolve => {
dnode.once('remote', api => {
resolve(api)
})
});
// Dostęp przez window
global.SignerApp = pageApi;
}Mamy to gotowe . Podczas łączenia nowej strony z background możemy to zobaczyć:

Pusty API i origin. Po stronie strony możemy wywołać funkcję hello tak:

Pracowanie z funkcjami callback w nowoczesnym JS jest niewłaściwe, dlatego napiszemy małego pomocnika do tworzenia dnode, który pozwala na przekazywanie do obiektu API w utils.
Obiekty API będą teraz wyglądać tak:
export class SignerApp {
popupApi() {
return {
hello: async () => "world"
}
}
...
}Otrzymywanie obiektu od remote w następujący sposób:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// Za pomocą narzędzi zmieniamy wszystkie callbacki na promise
resolve(transformMethods(cbToPromise, remoteApi))
})
});A wywołanie funkcji zwraca promisa:

Wersja z asynchronicznymi funkcjami jest dostępna .
Ogólnie rzecz biorąc, podejście z RPC i strumieniami wydaje się być dość elastyczne: możemy używać multiplexingu strumieniowego i tworzyć różne API do różnych zadań. W zasadzie, dnode można używać wszędzie, ważne jest, aby owinąć transport w formie strumienia nodejs.
Alternatywą jest format JSON, który realizuje protokół JSON RPC 2. Jednakże działa on z konkretne transporty (TCP i HTTP(S)), co w naszym przypadku nie ma zastosowania.
Wewnętrzny stan i localStorage
Będziemy potrzebować przechowywać wewnętrzny stan aplikacji — co najmniej klucze do podpisu. Możemy dosyć łatwo dodać stan aplikacji i metody do jego zmiany w popup API:
import {setupDnode} from "./utils/setupDnode";
export class SignerApp {
constructor() {
this.store = {
keys: [],
};
}
addKey(key) {
this.store.keys.push(key)
}
removeKey(index) {
this.store.keys.splice(index, 1)
}
popupApi() {
return {
addKey: async (key) => this.addKey(key),
removeKey: async (index) => this.removeKey(index)
}
}
...
} W background owinę wszystko w funkcję i zapiszę obiekt aplikacji w window, aby można było 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 UI i sprawdźmy, co się stało z stanem:

Stan musi być trwały, aby przy ponownym uruchomieniu klucze się nie zgubiły.
Będziemy przechowywać w localStorage, nadpisując go przy każdej zmianie. W przyszłości dostęp do niego będzie również potrzebny dla UI, a także chcemy subskrybować zmiany. W związku z tym wygodne będzie stworzenie obserwowalnego magazynu (observable storage) i subskrybowanie jego zmian.
Będziemy używać biblioteki mobx (). Wybór padł na nią, ponieważ wcześniej nie mieliśmy z nią do czynienia, a chcieliśmy ją poznać.
Dodamy inicjalizację początkowego stanu i uczynimy store obserwowalnym:
import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
export class SignerApp {
constructor(initState = {}) {
// Zewnętrznie store pozostanie tym samym obiektem, tylko teraz wszystkie jego pola stały się proxy, które śledzą dostęp do nich
this.store = observable.object({
keys: initState.keys || [],
});
}
// Metody, które zmieniają observable, powinny być opakowane dekoratorem
@action
addKey(key) {
this.store.keys.push(key)
}
@action
removeKey(index) {
this.store.keys.splice(index, 1)
}
...
}"Pod maską" mobx zastąpił wszystkie pola store na proxy i przechwytuje wszystkie odwołania do nich. Do tych odwołań będzie można się subskrybować.
Później często będę używać terminu "przy zmianie", chociaż to nie jest do końca poprawne. Mobx śledzi mianowicie dostęp do pól. Używane są gettery i settery obiektów proxy, które tworzy biblioteka.
Dekoratory action mają dwa cele:
- W trybie surowym z flagą enforceActions mobx zabrania bezpośredniej zmiany stanu. Dobrym tonem jest praca właśnie w trybie surowym.
- Nawet jeśli funkcja zmienia stan kilka razy - na przykład zmieniamy kilka pól w kilku liniach kodu - obserwatorzy są powiadamiani tylko po jej zakończeniu. Jest to szczególnie ważne w przypadku front-endu, gdzie zbędne aktualizacje stanu prowadzą do niepotrzebnego renderowania elementów. W naszym przypadku ani pierwsze, ani drugie nie są szczególnie istotne, jednak będziemy stosować się do najlepszych praktyk. Dekoratory zaleca się umieszczać na wszystkich funkcjach, które zmieniają stan obserwowanych pól.
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";
// Metody pomocnicze. Zapisują/odczytują obiekt z/do localStorage w postaci JSON-a pod kluczem '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;
}
// Ustawienie trwałości stanu
// Wynik reaction jest przypisany do zmiennej, aby subskrypcję można było anulować. Nie jest to potrzebne, pozostawione dla przykładu
const localStorageReaction = reaction(
() => toJS(app.store), // Funkcja selektora danych
saveState // Funkcja, która będzie wywoływana przy zmianie danych, które zwraca selektor
);
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 reaction. Ma dwa argumenty:
- Selektor danych.
- Handler, który będzie wywoływany z tymi danymi za każdym razem, gdy zostaną one zmienione.
W przeciwieństwie do redux, gdzie wyraźnie otrzymujemy stan jako argument, mobx zapamiętuje, do których dokładnie obserwowalnych pól odnosimy się wewnątrz selektora, i tylko przy ich zmianie wywołuje handler.
WaŜne jest zrozumienie, jak konkretnie mobx decyduje, do których observable się subskrybujemy. Gdybym w kodzie napisał selektor w ten sposób() => app.store, to reaction nigdy nie zostałby wywołany, ponieważ samo w sobie przechowywanie nie jest obserwowalne, obserwowalne są tylko jego pola.
Gdybym napisał to w ten sposób () => app.store.keys, to znowu nic by się nie wydarzyło, ponieważ przy dodawaniu/usuwaniu elementów z tablicy odniesienie do niej się nie zmienia.
Mobx po raz pierwszy wykonuje funkcję selektora i monitoruje tylko te obserwowalne, do których mieliśmy dostęp. Zrealizowano to za pomocą getterów proxy. Dlatego tutaj użyto wbudowanej funkcji toJS. Zwraca nowy obiekt, w którym wszystkie proxy zostały zastąpione oryginalnymi polami. W trakcie działania odczytuje wszystkie pola obiektu – w związku z tym uruchamiane są gettery.
W konsoli popup ponownie dodamy kilka kluczy. Tym razem znalazły się one także w localStorage:

Po ponownym załadowaniu strony background informacje pozostają na miejscu.
Cały kod aplikacji do tego momentu można zobaczyć .
Bezpieczne przechowywanie prywatnych kluczy
Przechowywanie prywatnych kluczy w postaci jawnej jest niebezpieczne: zawsze istnieje ryzyko, że ktoś włami się do nas, uzyska dostęp do naszego komputera itd. Dlatego w localStorage będziemy przechowywać klucze w postaci zaszyfrowanej hasłem.
Aby zwiększyć bezpieczeństwo, dodamy aplikacji stan locked, w którym dostęp do kluczy nie będzie możliwy w ogóle. Automatycznie przełączymy rozszerzenie w stan locked po upływie czasu.
Mobx pozwala przechowywać tylko minimalny zestaw danych, a resztę automatycznie obliczać na ich podstawie. Nazywa się to tzw. computed properties. Można je porównać do widoków w bazach danych:
import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Narzędzia do bezpiecznego szyfrowania łańcuchów. Używają crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";
export class SignerApp {
constructor(initState = {}) {
this.store = observable.object({
// Przechowujemy hasło i zaszyfrowane klucze. Jeśli hasło jest null - aplikacja jest zablokowana
password: null,
vault: initState.vault,
// Gettery dla pól obliczeniowych. Można to porównać z widokiem w bazie danych.
get locked() {
return this.password == null
},
get keys() {
return this.locked ?
undefined :
SignerApp._decryptVault(this.vault, this.password)
},
get initialized() {
return this.vault !== undefined
}
})
}
// Inicjalizacja pustego skarbca z nowym hasłem
@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
)
}
... // kod połączenia i api
// prywatne
_checkPassword(password) {
SignerApp._decryptVault(this.store.vault, password);
}
_checkLocked() {
if (this.store.locked) {
throw new Error('Aplikacja jest zablokowana')
}
}
// Metody do szyfrowania / deszyfrowania skarbca
static _encryptVault(obj, pass) {
const jsonString = JSON.stringify(obj)
return encrypt(jsonString, pass)
}
static _decryptVault(str, pass) {
if (str === undefined) {
throw new Error('Skarbiec nie zainicjalizowany')
}
try {
const jsonString = decrypt(str, pass)
return JSON.parse(jsonString)
} catch (e) {
throw new Error('Nieprawidłowe hasło')
}
}
}Teraz przechowujemy tylko zaszyfrowane klucze i hasło. Wszystko inne jest obliczane. Przejście do stanu zablokowanego realizujemy poprzez usunięcie hasła ze stanu. W publicznym API pojawiła się metoda do inicjalizacji skarbca.
Do szyfrowania napisano :
import CryptoJS from 'crypto-js'
// Używane do utrudnienia łamania hasła przez brute force. Na każdy wariant hasła, przestępca musi wykonać 5000 hashy
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 ma API bezczynności, dzięki któremu można subskrybować zdarzenie — zmiany stanu. Stan, odpowiednio, może być bezczynność, active i zablokowany. Dla bezczynności można ustawić czas oczekiwania, a zablokowany jest ustawiany, gdy sama OS jest blokowana. Zmienimy również selektor do 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;
}
// Teraz wyraźnie wywołujemy pole, do którego będzie dostęp, reakcja będzie działać poprawnie
reaction(
() => ({
vault: app.store.vault
}),
saveState
);
// Czas oczekiwania bezczynności, kiedy zdarzenie zostanie wywołane
extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
// Jeśli użytkownik zablokował ekran lub nie działał przez określony czas, blokujemy aplikację
extensionApi.idle.onStateChanged.addListener(state => {
if (['locked', 'idle'].indexOf(state) > -1) {
app.lock()
}
});
// Połączenie z innymi kontekstami
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)
}
}
}Kod do tego kroku znajduje się .
Transakcje
Zatem doszliśmy do najważniejszego: tworzenia i podpisywania transakcji w blockchainie. Będziemy używać blockchaina WAVES i biblioteki .
Na początku dodamy do stanu tablicę wiadomości, które należy podpisać, a następnie — metody dodawania nowej wiadomości, potwierdzania 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) {
// Dla każdej wiadomości tworzymy metadane z id, statusem, czasem utworzenia itd.
const message = observable.object({
id: uuid(), // Identyfikator, używam uuid
origin, // Origin będziemy później wyświetlać w interfejsie
data, //
status: 'new', // Statusów będzie cztery: new, signed, rejected i failed
timestamp: Date.now()
});
console.log(`new message: ${JSON.stringify(message, null, 2)}`);
this.store.messages.push(message);
// Zwracamy promesę, wewnątrz której mobx monitoruje zmiany statusu wiadomości. Gdy tylko status się zmieni, wykonamy resolve
return new Promise((resolve, reject) => {
reaction(
() => message.status, // Obserwujemy status wiadomości
(status, reaction) => { // drugą argument to referencja do samej reakcji, aby można było ją zniszczyć wewnątrz wywołania
switch (status) {
case 'signed':
resolve(message.data);
break;
case 'rejected':
reject(new Error('Użytkownik odrzucił wiadomość'));
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(`Nie znaleziono wiadomości z 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(`Nie znaleziono wiadomości z id:${id}`);
message.status = 'rejected'
}
...
}Podczas otrzymywania nowej wiadomości dodajemy do niej metadane, tworzymy observable i dodajemy do store.messages.
Jeśli nie zrobisz tego ręcznie, mobx zrobi to sam przy dodawaniu do tablicy messages. Jednak utworzy nowy obiekt, do którego nie będziemy mieć odniesienia, a będzie nam potrzebna do następnego kroku. observable Następnie zwracamy promesę, która realizuje się przy zmianie statusu wiadomości. Za status odpowiada reakcja, która sama się „zabije” przy zmianie statusu.
Kod metod
reject approve i jest bardzo prosty: po prostu zmieniamy status wiadomości, wcześniej ją podpisując, jeśli to konieczne. bardzo prosto: po prostu zmieniamy status wiadomości, wcześniej ją podpisując, jeśli to konieczne.
Akceptację i odrzucenie przenosimy do interfejsu API, newMessage — na stronę API:
export class SignerApp {
...
popupApi() {
return {
addKey: async (key) => this.addKey(key),
removeKey: async (index) => this.removeKey(index),
lock: async () => this.lock(),
unlock: async (password) => this.unlock(password),
initVault: async (password) => this.initVault(password),
approve: async (id, keyIndex) => this.approve(id, keyIndex),
reject: async (id) => this.reject(id)
}
}
pageApi(origin) {
return {
signTransaction: async (txParams) => this.newMessage(txParams, origin)
}
}
...
}Teraz spróbujemy podpisać transakcję za pomocą rozszerzenia:

W zasadzie wszystko gotowe, zostało .
UI
Interfejs potrzebuje dostępu do stanu aplikacji. Po stronie UI stworzymy observable stan i dodamy do API funkcję, która będzie zmieniać ten stan. Dodamy observable do obiektu API, uzyskanego z background:
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() {
// Łączymy się z portem, tworzymy z niego strumień
const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
const connectionStream = new PortStream(backgroundPort);
// Tworzymy pusty observable dla stanu background'a
let backgroundState = observable.object({});
const api = {
// Przekazujemy backgroundowi funkcję, która będzie aktualizować observable
updateState: async state => {
Object.assign(backgroundState, state)
}
};
// Tworzymy obiekt RPC
const dnode = setupDnode(connectionStream, api);
const background = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
resolve(transformMethods(cbToPromise, remoteApi))
})
});
// Dodajemy do background observable ze stanem
background.state = backgroundState;
if (DEV_MODE) {
global.background = background;
}
// Uruchamiamy interfejs
await initApp(background)
}
Na końcu uruchamiamy renderowanie interfejsu aplikacji. To aplikacja react. Obiekt background jest po prostu przekazywany za pomocą props. Oczywiście lepiej byłoby stworzyć osobny serwis dla metod i store dla stanu, ale w ramach tego artykułu to wystarczy:
import {render} from 'react-dom'
import App from './App'
import React from "react";
// Inicjalizujemy aplikację z obiektem background w postaci props
export async function initApp(background){
render(
,
document.getElementById('app-content')
);
}
Z pomocą mobx bardzo łatwo uruchomić renderowanie przy zmianie danych. Po prostu używamy dekoratora observer z pakietu komponentu, a renderowanie będzie automatycznie wywoływane po zmianie jakichkolwiek observable, do których odnosi się komponent. Nie są potrzebne żadne mapStateToProps ani connect, jak w redux. Wszystko działa od razu «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 // Komponent z tym dekoratorem automatycznie wywoła metodę render, jeśli zmienią się observable, do których się odnosi
export default class App extends Component {
// Oczywiście poprawnie wyodrębnić logikę renderowania stron do routingu i nie używać zagnieżdżonych operatorów ternarnych,
// oraz powiązać observable i metody background bezpośrednio z tymi komponentami, które ich używają
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()}>Zablokuj aplikację</button>}
{initialized && <button onclick="{()" > deleteVault()}>Usuń wszystkie klucze i zainicjalizuj</button>}
</div>
</Fragment>
}
}Inne komponenty można zobaczyć w kodzie .
Teraz w klasie aplikacji należy stworzyć selektor stanu dla UI i powiadomić UI o jego zmianie. W tym celu dodamy metodę getState i reaction, wywołującą 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) => {
// Tworzymy reakcję na zmiany stanu, która wywoła zdalną procedurę oraz zaktualizuje stan w procesie ui
const updateStateReaction = reaction(
() => this.getState(),
(state) => remote.updateState(state),
// Trzecim argumentem można przekazywać parametry. fireImmediatly oznacza, że reakcja wykona się po raz pierwszy od razu.
// Jest to konieczne, aby uzyskać początkowy stan. Delay pozwala ustawić debounce
{fireImmediately: true, delay: 500}
);
// Usuniemy subskrypcję po odłączeniu klienta
dnode.once('end', () => updateStateReaction.dispose())
})
}
...
}Po otrzymaniu obiektu remote tworzy się reaction na zmianę stanu, która wywołuje funkcję po stronie UI.
Ostatni szlif — dodamy wyświetlanie nowych wiadomości na ikonie rozszerzenia:
function setupApp() {
...
// Reakcja na ustawienie tekstu badge.
reaction(
() => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
text => extensionApi.browserAction.setBadgeText({text}),
{fireImmediately: true}
);
...
}Więc aplikacja jest gotowa. Strony internetowe mogą żądać podpisania transakcji:


Kod dostępny pod tym .
Podsumowanie
Jeśli dotarłeś do końca artykułu, ale masz pytania, możesz je zadać w . Tam również znajdziesz commity dla każdego zaznaczonego kroku.
A jeśli chcesz zobaczyć kod prawdziwego rozszerzenia, możesz go znaleźć .
Kod, repozytorium i opis działania od
Źródło: habr.com
