
Na rozdíl od běžné architektury „klient-server“ jsou pro decentralizované aplikace charakteristické:
- Nepotřebnost uchovávat databázi s přihlašovacími údaji a hesly uživatelů. Informace pro přístup jsou uchovávány výhradně u samotných uživatelů a ověřování jejich platnosti probíhá na úrovni protokolu.
- Nepotřebnost využívat server. Logika aplikace může být vykonávána v blockchainové síti, kde je také možné uchovávat potřebné množství dat.
Existují dvě relativně bezpečné úložiště pro uživatelské klíče — hardwarové peněženky a prohlížečová rozšíření. Hardwarové peněženky jsou většinou maximálně bezpečné, ale obtížně použitelné a rozhodně nejsou zdarma, zatímco prohlížečová rozšíření představují ideální kombinaci bezpečnosti a snadnosti použití, a navíc mohou být pro koncové uživatele zcela zdarma.
S ohledem na to jsme chtěli vytvořit maximálně bezpečné rozšíření, které usnadňuje vývoj decentralizovaných aplikací a poskytuje jednoduché API pro práci s transakcemi a podpisy.
O této zkušenosti vám budeme vyprávět níže.
V článku bude krok za krokem návod, jak napsat prohlížečové rozšíření, včetně příkladů kódu a snímků obrazovky. Celý kód můžete najít v . Každý commit logicky odpovídá sekci tohoto článku.
Krátká historie prohlížečových rozšíření
Prohlížečová rozšíření existují už poměrně dlouho. V Internet Exploreru se objevila již v roce 1999, ve Firefoxu v roce 2004. Přesto dlouho neexistoval jednotný standard pro rozšíření.
Dá se říci, že ten se objevil spolu s rozšířeními ve čtvrté verzi Google Chrome. Samozřejmě, žádná specifikace tehdy neexistovala, ale právě API Chrome se stalo jejím základem: díky tomu, že získal většinu trhu prohlížečů a měl integrovaný obchod s aplikacemi, Chrome fakticky stanovil standard pro prohlížečová rozšíření.
Mozilla měla svůj vlastní standard, ale vidějíc popularitu rozšíření pro Chrome, společnost se rozhodla vytvořit kompatibilní API. V roce 2015 na návrh Mozilly byla v rámci World Wide Web Consortium (W3C) vytvořena speciální skupina pro práci na specifikacích krosprohlížečových rozšíření.
Základem byl vzat již existující API rozšíření pro Chrome. Práce probíhala za podpory Microsoftu (Google se účastnit vývoje standardu odmítl), a v důsledku toho se objevil jeho koncept. .
Formálně specifikaci podporují Edge, Firefox a Opera (všimněte si, že v tomto seznamu chybí Chrome). Ve skutečnosti je však standard do značné míry kompatibilní i s Chrome, protože byl ve skutečnosti napsán na základě jeho rozšíření. O WebExtensions API se můžete dozvědět více. .
Struktura rozšíření
Jediný soubor, který je pro rozšíření nezbytný, je manifest (manifest.json). Je to také "vstupní bod" do rozšíření.
Manifest
Podle specifikace je soubor manifestu platným JSON souborem. Kompletní popis klíčů manifestu a informací o tom, které klíče jsou podporovány v jakém prohlížeči, můžete nalézt zde. .
Klíče, které nejsou ve specifikaci, "mohou" být ignorovány (jak Chrome, tak Firefox hlásí chyby, ale rozšíření i nadále fungují).
A chtěl bych upozornit na některé body.
- background — objekt, který zahrnuje následující pole:
- scripts — pole skriptů, které budou provedeny v background kontextu (o tom si povíme později);
- page — místo skriptů, které budou vykonávány na prázdné stránce, lze zadat HTML s obsahem. V takovém případě bude pole skript ignorováno a skripty je třeba vložit na stránku s obsahem;
- trvalé — binární příznak, pokud není specifikován, prohlížeč "ukončí" proces na pozadí, když usoudí, že nic nedělá, a znovu jej spustí podle potřeby. Jinak bude stránka uvolněna pouze při zavření prohlížeče. Není podporováno v Firefoxu.
- content_scripts — pole objektů, které umožňuje načítat různé skripty na různé webové stránky. Každý objekt obsahuje následující důležitá pole:
- matches — , podle kterého se určuje, zda bude konkrétní content script zahrnut nebo ne.
- js — seznam skriptů, které budou načteny v daném shodě;
- exclude_matches — vylučuje z pole
matchURL, které vyhovují tomuto poli.
- page_action — ve skutečnosti je objektem, který se stará o ikonu, která se zobrazuje vedle adresního řádku v prohlížeči, a interakce s ní. Také umožňuje zobrazit vyskakovací okno, které je určeno pomocí vlastního HTML, CSS a JS.
- default_popup — cesta k HTML souboru s popup rozhraním, může obsahovat CSS a JS.
- permissions — pole pro správu práv rozšíření. Existují tři typy práv, které jsou podrobně popsány
- web_accessible_resources — zdroje rozšíření, které může vyžadovat webová stránka, například obrázky, soubory JS, CSS, HTML.
- externally_connectable — zde lze výslovně uvést ID jiných rozšíření a domény webových stránek, z kterých se lze připojovat. Doména může být druhého stupně a vyšší. Neplatí pro Firefox.
Kontext provádění
Rozšíření má tři kontexty provádění kódu, to znamená, že aplikace se skládá ze tří částí s různou úrovní přístupu k API prohlížeče.
Kontext rozšíření
Zde je k dispozici většina API. V tomto kontextu „žijí“:
- Pozadí stránka — “backend” část rozšíření. Soubor je uveden v manifestu pod klíčem “background”.
- Vyskakovací stránka — vyskakovací stránka, která se objeví po kliknutí na ikonu rozšíření. V manifestu
browser_action->default_popup. - Vlastní stránka — stránka rozšíření, „žijící“ v samostatné záložce typu
chrome-extension:///customPage.html.
Tento kontext existuje nezávisle na oknech a záložkách prohlížeče. Pozadí stránka existuje v jediném exempláři a pracuje vždy (výjimka — event page, kdy je skript na pozadí spuštěn událostí a „umírá“ po jejím vykonání). Vyskakovací stránka existuje, když je otevřeno vyskakovací okno, a Vlastní stránka — dokud je otevřena záložka s ním. Z tohoto kontextu není přístup k jiným záložkám a jejich obsahu.
Kontext skriptu obsahu
Soubory skriptu obsahu se spouští spolu s každou záložkou prohlížeče. Mají přístup k části API rozšíření a k DOM stromu webové stránky. Právě skripty obsahu odpovídají za interakci se stránkou. Rozšíření, která manipulují s DOM stromem, to dělají ve skriptech obsahu — například blokátory reklamy nebo překladače. Skript obsahu může také komunikovat se stránkou prostřednictvím standardního postMessage.
Kontext webové stránky
To je samotná webová stránka. K rozšíření nemá žádný vztah a nemá k němu přístup, kromě případů, kdy je v manifestu výslovně uvedena doména této stránky (jak je popsáno níže).
Výmena zpráv
Různé části aplikace musí mezi sebou vyměňovat zprávy. K tomu existuje API runtime.sendMessage pro odeslání zprávy background a tabs.sendMessage pro odeslání zprávy na stránku (skriptu obsahu, vyskakovacímu oknu nebo webové stránce, pokud je k dispozici externally_connectable). Níže je uveden příklad při volání 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))
}
)Pro plnohodnotnou komunikaci můžete vytvářet spojení přes runtime.connect. V odpovědi obdržíme runtime.Port, do kterého, dokud je otevřené, lze posílat libovolné množství zpráv. Na straně klienta, například, contentscript, to vypadá takto:
// Опять же 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"});
Server nebo 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) {
...
});Existuje také událost onDisconnect a metoda odpojení.
Schéma aplikace
Vytvořme rozšíření pro prohlížeč, které uchovává soukromé klíče, poskytuje přístup k veřejným informacím (adresa, veřejný klíč komunikující se stránkou) a umožňuje třetím aplikacím požádat o podpis transakcí.
Vývoj aplikace
Naše aplikace by měla interagovat s uživateli i poskytovat stránce API pro volání metod (například pro podpis transakcí). Omezit se pouze na contentscript to nebude možné, protože má přístup pouze k DOM, ale ne k JS stránky. Připojit se přes runtime.connect nemůžeme, protože API je potřeba na všech doménách, a v manifestu lze uvést pouze konkrétní. Nakonec bude schéma vypadat takto:

Bude další skript — inpage, který budeme injektovat na stránku. Bude se vykonávat v jejím kontextu a poskytne API pro práci s rozšířením.
Začátek
Veškerý kód browserového rozšíření je dostupný na . V průběhu popisu budou odkazy na commity.
Začněme s manifestem:
{
// Název a popis, verze. To vše bude viditelné v prohlížeči na chrome://extensions/?id=<id rozšíření>
"name": "Signer",
"description": "Ukázka rozšíření",
"version": "0.0.1",
"manifest_version": 2,
// Skripty, které budou vykonávány v pozadí, může jich být více
"background": {
"scripts": ["background.js"]
},
// Jaký HTML použít pro popup
"browser_action": {
"default_title": "Moje rozšíření",
"default_popup": "popup.html"
},
// Obsahové skripty.
// Máme jeden objekt: pro všechny url začínající s http nebo https spouštíme
// contenscript context se skriptem contentscript.js. Spouštíme ho hned po načtení dokumentu pro všechny rámce
"content_scripts": [
{
"matches": [
"http:/*/*",
"https:/*/*"
],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
// Povolen přístup k localStorage a idle api
"permissions": [
"storage",
// "unlimitedStorage",
//"clipboardWrite",
"idle"
//"activeTab",
//"webRequest",
//"notifications",
//"tabs"
],
// Zde se uvádějí zdroje, k nimž bude mít přístup webová stránka. Tedy budou moci být dotazovány fetchem nebo prostě xhr
"web_accessible_resources": ["inpage.js"]
}Vytvoříme prázdné soubory background.js, popup.js, inpage.js a contentscript.js. Přidáme popup.html — a naši aplikaci již můžeme nahrát do Google Chrome a ověřit, že funguje.
Abychom se o tom ujistili, můžeme si vzít kód . Kromě toho, co jsme udělali, je na odkazu nastavena sestava projektu pomocí webpacku. Pro přidání aplikace do prohlížeče je nutné v chrome://extensions zvolit načíst rozbalené a složku s příslušným rozšířením — v našem případě dist.

Nyní je naše rozšíření nainstalováno a funguje. Nástroje pro vývojáře pro různé kontexty můžete spustit následujícím způsobem:
popup ->

Přístup k obsahu skriptu se provádí prostřednictvím konzole samotné stránky, na které je spuštěn.
Výmena zpráv
Takže musíme nastavit dvě komunikační kanály: inpage <-> background a popup <-> background. Samozřejmě můžete jednoduše posílat zprávy do portu a vymyslet si vlastní protokol, ale osobně mám raději přístup, který jsem viděl v projektu s otevřeným zdrojovým kódem metamask.
Toto je prohlížečové rozšíření pro práci se sítí Ethereum. Různé části aplikace komunikují přes RPC pomocí knihovny dnode. Ta umožňuje rychlou a pohodlnou organizaci výměny, pokud ji jako přepravní prostředek poskytnete nodejs stream (má se na mysli objekt implementující stejný rozhraní):
import Dnode from "dnode/browser";
// V tomto příkladu se dohodneme, že klient vzdáleně volá funkce na serveru, i když nám nic nebrání udělat to obousměrně
// Server
// API, které chceme poskytnout
const dnode = Dnode({
hello: (cb) => cb(null, "world")
})
// Přenosový médium, na kterém bude dnode pracovat. Jakýkoli nodejs stream. V prohlížeči je knihovna 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)
// Klient
const dnodeClient = Dnode() // Volání bez argumentu znamená, že neposkytujeme API na druhé straně
// Vytiskne do konzole world
dnodeClient.once('remote', remote => {
remote.hello(((err, value) => console.log(value)))
})Nyní vytvoříme třídu aplikace. Ta bude vytvářet objekty API pro popup a webovou stránku a také pro ně vytvářet dnode:
import Dnode from 'dnode/browser';
export class SignerApp {
// Vrátí API objekt pro ui
popupApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Vrátí API objekt pro stránku
pageApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Připojuje popup ui
connectPopup(connectionStream){
const api = this.popupApi();
const dnode = Dnode(api);
connectionStream.pipe(dnode).pipe(connectionStream);
dnode.on('remote', (remote) => {
console.log(remote)
})
}
// Připojuje stránku
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)
})
}
}Od této chvíle místo globálního objektu Chrome používáme extentionApi, které se připojuje k Chrome v prohlížeči Google a k browser v ostatních. To se dělá kvůli cross-browser kompatibilitě, ale v rámci tohoto článku by bylo možné použít i prostě 'chrome.runtime.connect'.
Vytvoříme instanci aplikace ve background skriptu:
import {extensionApi} from './utils/extensionApi';
import {PortStream} from './utils/PortStream';
import {SignerApp} from './SignerApp';
const app = new SignerApp();
// onConnect se spouští při připojení 'procesů' (contentscript, popup, nebo stránka rozšíření)
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
// Při navazování spojení můžeme specifikovat jméno, podle tohoto jména určujeme, kdo se k nám připojil, contentscript nebo ui
if (processName === 'contentscript'){
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
}else{
app.connectPopup(portStream)
}
}Protože dnode pracuje se streamy a my získáváme port, je potřeba třída-adaptér. Ta je vytvořena s použitím knihovny readable-stream, která implementuje nodejs streamy v prohlížeči:
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()
}
}Nyní vytváříme připojení v 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(){
// Stejně jako v třídě aplikace vytvoříme port, zabalíme ho do streamu, uděláme 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)
})
});
// Zpřístupníme objekt API z konzole
if (DEV_MODE){
global.background = background;
}
}Poté vytvoříme připojení ve skriptu obsahu:
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 {
// injekce skriptu do stránky
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);
}
}Jelikož API potřebujeme nejen ve skriptu obsahu, ale přímo na stránce, uděláme dvě věci:
- Vytvoříme dva streamy. Jeden – směrem k stránce, nad postMessage. K tomu použijeme tento balíček . Propojíme je. Nyní bude stránka mít stream do pozadí.
runtime.connectInjektujeme skript do DOM. Stáhneme skript (přístup k němu byl povolen v manifestu) a vytvoříme tag - s jeho obsahem uvnitř:
scriptimport PostMessageStream from 'post-message-stream'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream";setupConnection(); injectScript();function setupConnection(){ // Stream k pozadí const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'}); const backgroundStream = new PortStream(backgroundPort);// Stream k stránce const pageStream = new PostMessageStream({ name: 'content', target: 'page', });pageStream.pipe(backgroundStream).pipe(pageStream); }function injectScript(){ try { // injekce skriptu do stránky 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); } }
Nyní vytvoříme objekt api v inpage a nastavíme ho jako global:Nyní vytvoříme objekt api v inpage a zaregistrujeme jej global:
import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';
setupInpageApi().catch(console.error);
async function setupInpageApi() {
// Proud k obsahovému skriptu
const connectionStream = new PostMessageStream({
name: 'page',
target: 'content',
});
const dnode = Dnode();
connectionStream.pipe(dnode).pipe(connectionStream);
// Získání objektu API
const pageApi = await new Promise(resolve => {
dnode.once('remote', api => {
resolve(api)
})
});
// Přístup přes window
global.SignerApp = pageApi;
}Máme připraveno . Při připojení nové stránky k pozadí to můžeme vidět:

Prázdné API a origin. Na straně stránky můžeme zavolat funkci hello takto:

Práce s callback funkcemi v moderním JS je už out, proto napíšeme malého pomocníka pro vytvoření dnode, který umožňuje předat do objektu API funkce v utilitách.
Objekty API nyní budou vypadat takto:
export class SignerApp {
popupApi() {
return {
hello: async () => "world"
}
}
...
}Získání objektu od remote následujícím způsobem:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// Pomocí utilit měníme všechny callbacky na promise
resolve(transformMethods(cbToPromise, remoteApi))
})
});A zavolání funkcí vrací promise:

Verze s asynchronními funkcemi je k dispozici .
Celkově se přístup s RPC a streamy zdá dost flexibilní: můžeme použít multiplexing a vytvořit několik různých API pro různé úkoly. V podstatě, dnode může být použit kdekoliv, hlavně jej obalit do formy nodejs streamu.
Alternativou je formát JSON, který implementuje protokol JSON RPC 2. Nicméně pracuje s konkrétními transporty (TCP a HTTP(S)), což v našem případě není použitelné.
Interní stav a localStorage
Budeme potřebovat ukládat interní stav aplikace — minimálně klíče pro podepisování. Můžeme dost snadno přidat stav aplikaci a metody pro jeho změnu do 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)
}
}
...
} V pozadí všechno zabalíme do funkce a zapíšeme objekt aplikace do window, abychom s ním mohli pracovat z konzole:
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)
}
}
}Přidáme do UI několik klíčů z konzole a podíváme se, co se stalo se stavem:

Stav je třeba udělat trvalým, aby se klíče při restartu neztratily.
Budu je uchovávat v localStorage a při každé změně ho přepisovat. Vzhledem k tomu, že k němu bude později také nutný přístup pro UI, chtěl bych se také přihlásit k odběru změn. Na základě toho by bylo užitečné vytvořit pozorovatelné úložiště (observable storage) a přihlásit se k jeho změnám.
Budeme používat knihovnu mobx (). Vybrali jsme ji, protože jsme s ní dosud nepracovali a chtěli jsme se ji naučit.
Přidáme inicializaci počátečního stavu a uděláme úložiště pozorovatelné:
import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
export class SignerApp {
constructor(initState = {}) {
// Externě zůstane úložiště tím stejným objektem, jen nyní se všechna jeho pole stala proxy, která sleduje přístup k nim
this.store = observable.object({
keys: initState.keys || [],
});
}
// Metody, které mění observable, je dobré obalovat dekorátorem
@action
addKey(key) {
this.store.keys.push(key)
}
@action
removeKey(index) {
this.store.keys.splice(index, 1)
}
...
}„Pod kapotou“ mobx nahradil všechna pole úložiště proxy a interceptuje všechny žádosti o ně. K těmto žádostem se bude možné přihlásit.
Dále budu často používat termín „při změně“, i když to není zcela přesné. Mobx sleduje přístup k polím. Používají se getter a setter proxy objektů, které knihovna vytváří.
Dekorátory akce slouží dvěma účelům:
- V přísném režimu s příznakem enforceActions mobx zakazuje měnit stav přímo. Dobrou praxí je pracovat právě v přísném režimu.
- I když funkce mění stav několikrát — například měníme několik polí v několika řádcích kódu — obsluhy jsou upozorněny pouze po jejím dokončení. To je zvlášť důležité pro frontend, kde nadbytečné aktualizace stavu vedou k zbytečnému renderování prvků. V našem případě to není tolik aktuální, nicméně budeme se řídit osvědčenými postupy. Dekorátory se obvykle přidávají na všechny funkce, které mění stav sledovaných polí.
Do pozadí přidáme inicializaci a ukládání stavu do localStorage:
import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Pomocné metody. Ukládají/čtou objekt do/z localStorage ve formátu JSON pod klíčem '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;
}
// Nastavení trvalého ukládání stavu
// Výsledek reakce je přiřazen proměnné, aby bylo možné zrušit předplatné. To nepotřebujeme, je to ponecháno pro příklad
const localStorageReaction = reaction(
() => toJS(app.store), // Funkce-selektor dat
saveState // Funkce, která bude volána při změně dat, která vychází ze selektoru
);
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)
}
}
}Zajímavá je zde funkce reaction. Má dva argumenty:
- Selektor dat.
- Zpracovatel, který bude vyvolán s těmito daty pokaždé, když se změní.
Na rozdíl od redux, kde jasně získáváme stav jako argument, mobx si pamatuje, na které konkrétní observable se v selektoru odvoláváme, a pouze při jejich změně vyvolává zpracovatel.
Je důležité pochopit, jak přesně mobx rozhoduje, na které observable se přihlašujeme. Kdybych v kódu napsal selektor takto() => app.store, tak reakce nikdy nebude vyvolána, protože samo o sobě úložiště není sledovatelné, sledovatelné jsou pouze jeho pole.
Kdybych napsal takto () => app.store.keys, opět by se nic nestalo, protože při přidávání/odstraňování prvků pole se odkaz na něj měnit nebude.
Mobx poprvé vykonává funkci selektoru a sleduje pouze ty observable, k nimž jsme měli přístup. Toho je dosaženo prostřednictvím proxy getterů. Proto je zde použita vestavěná funkce toJS. Ta vrací nový objekt, v němž jsou všechny proxy nahrazeny původními poli. Během provádění čte všechna pole objektu - a tím pádem se aktivují gettery.
V konzoli popup opět přidáme několik klíčů. Tentokrát se dostali také do localStorage:

Po restartování background stránky zůstává informace na místě.
Celý kód aplikace do této chvíle lze zobrazit .
Bezpečné ukládání soukromých klíčů
Ukládat soukromé klíče v otevřené podobě není bezpečné: vždy existuje riziko, že vás napadnou, získají přístup k vašemu počítači a tak dále. Proto budeme v localStorage uchovávat klíče ve formě šifrované heslem.
Pro větší bezpečnost přidáme aplikaci stav locked, ve kterém nebude přístup k klíčům vůbec. Automaticky převedeme rozšíření do stavu locked po uplynutí časového limitu.
Mobx umožňuje uchovávat pouze minimální množství dat a zbytek automaticky vypočítá na jejich základě. Těmto se říká computed properties. Lze je porovnat s view v databázích:
import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Utility for secure string encryption. Uses crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";
export class SignerApp {
constructor(initState = {}) {
this.store = observable.object({
// Stores the password and encrypted keys. If password is null - application is locked
password: null,
vault: initState.vault,
// Getters for computed fields. Can be compared to views in the database.
get locked() {
return this.password == null
},
get keys() {
return this.locked ?
undefined :
SignerApp._decryptVault(this.vault, this.password)
},
get initialized() {
return this.vault !== undefined
}
})
}
// Initialization of an empty vault with a new password
@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
)
}
... // Code for connection and API
// private
_checkPassword(password) {
SignerApp._decryptVault(this.store.vault, password);
}
_checkLocked() {
if (this.store.locked) {
throw new Error('App is locked')
}
}
// Methods for vault encryption/decryption
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')
}
}
}Nyní uchováváme pouze šifrované klíče a heslo. Všechno ostatní se počítá. Převedení do stavu uzamčené provádíme odstraněním hesla ze stavu. Ve veřejném API se objevila metoda pro inicializaci úložiště.
Pro šifrování byly napsány :
import CryptoJS from 'crypto-js'
// Používá se k ztížení pokusů o uhádnutí hesla. Na každou variantu hesla je útočník nucen vytvořit 5000 hashů
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)
}Prohlížeč má idle API, přes které se lze přihlásit k události — změny stavu. Stav může být tedy nečinnost, aktivní a uzamčený. Pro idle lze nastavit časový limit, zatímco uzamčený se nastavuje, když je samotný operační systém zablokován. Také změníme selektor pro ukládání do 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;
}
// Nyní explicitně voláme pole, ke kterému dojde k přístupu, reakce bude fungovat normálně
reaction(
() => ({
vault: app.store.vault
}),
saveState
);
// Časový limit nečinnosti, při kterém se spustí událost
extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
// Pokud uživatel zamkne obrazovku nebo nečiní během daného intervalu, uzamkneme aplikaci
extensionApi.idle.onStateChanged.addListener(state => {
if (['locked', 'idle'].indexOf(state) > -1) {
app.lock()
}
});
// Připojit k jiným kontextům
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)
}
}
}Kód před tímto krokem se nachází .
Transakce
Takže jsme se dostali k tomu nejdůležitějšímu: vytváření a podepisování transakcí v blockchainu. Použijeme blockchain WAVES a knihovnu .
Nejprve přidáme do stavu pole zpráv, které je třeba podepsat, poté — metody pro přidání nové zprávy, potvrzení podpisu a odmítnutí:
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) {
// Pro každou zprávu vytváříme metadata s id, stavem, časem vytvoření atd.
const message = observable.object({
id: uuid(), // Identifikátor, používáme uuid
origin, // Origin budeme později zobrazovat v rozhraní
data, //
status: 'new', // Stavů bude čtyři: new, signed, rejected a failed
timestamp: Date.now()
});
console.log(`new message: ${JSON.stringify(message, null, 2)}`);
this.store.messages.push(message);
// Vracíme promys, ve kterém mobx sleduje změny ve zprávě. Jakmile se stav změní, vyřešíme ho
return new Promise((resolve, reject) => {
reaction(
() => message.status, // Budeme sledovat stav zprávy
(status, reaction) => { // Druhý argument je odkaz na samotnou reakci, aby ji bylo možné zničit uvnitř volání
switch (status) {
case 'signed':
resolve(message.data);
break;
case 'rejected':
reject(new Error('Uživatel odmítl zprávu'));
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(`Žádná zpráva s 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(`Žádná zpráva s id:${id}`);
message.status = 'rejected'
}
...
}Při obdržení nové zprávy přidáváme do ní metadata, děláme observable a přidáváme do store.messages.
Pokud to neuděláme observable ručně, mobx to udělá sám při přidání do pole messages. Vytvoří však nový objekt, na který nebudeme mít odkaz, a ten bude potřebný pro další krok.
Dále vracíme promys, který se vyřeší při změně stavu zprávy. O stav se stará reakce, která se sama "zabije" při změně stavu.
Kód metod approve a reject je velmi jednoduchý: pouze měníme stav zprávy, přičemž pokud je to potřeba, ji předtím podepisujeme.
Schválení a zamítnutí provádíme v API UI, newMessage — na stránkách 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)
}
}
...
}Nyní zkusíme podepsat transakci pomocí rozšíření:

V zásadě je vše připraveno, zbývá .
UI
Rozhraní potřebuje přístup k stavu aplikace. Na straně UI uděláme observable stav a přidáme do API funkci, která tento stav mění. Přidáme observable do objektu API, získaného 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() {
// Připojujeme se k portu, vytvoříme ze něj stream
const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
const connectionStream = new PortStream(backgroundPort);
// Vytvoříme prázdný observable pro stav background'a
let backgroundState = observable.object({});
const api = {
// Předáváme backgroundu funkci, která bude aktualizovat observable
updateState: async state => {
Object.assign(backgroundState, state)
}
};
// Děláme RPC objekt
const dnode = setupDnode(connectionStream, api);
const background = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
resolve(transformMethods(cbToPromise, remoteApi))
})
});
// Přidáváme do background observable se stávem
background.state = backgroundState;
if (DEV_MODE) {
global.background = background;
}
// Spuštění rozhraní
await initApp(background)
}
Na konci spouštíme renderování uživatelského rozhraní aplikace. Je to react aplikace. Objektem background se jednoduše předává pomocí props. Samozřejmě by bylo správné udělat samostatnou službu pro metody a store pro stav, ale v rámci tohoto článku je to dostatečné:
import {render} from 'react-dom'
import App from '. /App'
import React from "react";
// Inicializujeme aplikaci s objektem background v roli props
export async function initApp(background){
render(
,
document.getElementById('app-content')
);
}
Pomocí mobx je velmi snadné spustit renderování při změně dat. Jednoduše použijeme dekorátor observer z balíčku na komponent, a render bude automaticky vyvolán při změně jakýchkoli observable, na které komponent odkazuje. Není třeba žádný mapStateToProps nebo connect, jako v redux. Všechno funguje ihned "z krabice":
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 s tímto dekorátorem bude automaticky vyvolán renderovací metoda, pokud dojde ke změnám v observable, na které se odkazuje
export default class App extends Component {
\/\/ Správně by bylo vyčlenit logiku renderování stránek do routingu a nepoužívat vnořené ternární operátory,
\/\/ a připojit observable a metody background přímo k těm komponentám, které je používají
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()}>Zamknout aplikaci</button>}
{initialized && <button onclick="{()" > deleteVault()}>Smazat všechny klíče a inicializovat</button>}
</div>
</Fragment>
}
}Další komponenty si můžete prohlédnout v kódu .
Nyní je třeba v aplikaci vytvořit selektor stavu pro UI a při jeho změně upozornit UI. K tomu přidáme metodu getState a reaction, která vyvolává 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 {
...
// veřejné
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) => {
// Vytvoříme reakci na změny stavu, která vyvolá vzdálenou proceduru a aktualizuje stav v UI procesu
const updateStateReaction = reaction(
() => this.getState(),
(state) => remote.updateState(state),
// Třetím argumentem můžeme předat parametry. fireImmediately znamená, že reakce proběhne poprvé okamžitě.
// To je nutné, abychom získali počáteční stav. Delay umožňuje nastavit debounce
{fireImmediately: true, delay: 500}
);
// Odstraníme odběr při odpojení klienta
dnode.once('end', () => updateStateReaction.dispose())
})
}
...
}Při obdržení objektu remote se vytváří reaction na změnu stavu, který vyvolává funkci na straně UI.
Poslední úprava – přidáme zobrazení nových zpráv na ikoně rozšíření:
function setupApp() {
...
// Reakce na nastavení textu badge.
reaction(
() => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
text => extensionApi.browserAction.setBadgeText({text}),
{fireImmediately: true}
);
...
}Takže aplikace je připravena. Webové stránky mohou požadovat podpis transakcí:


Kód je dostupný na této .
Závěr
Pokud jste dočetli článek až do konce, ale máte stále dotazy, můžete je položit v . Zde najdete také commity ke každému označenému kroku.
A pokud vás zajímá podívat se na kód skutečného rozšíření, budete ho moci najít .
Kód, repo a popis práce od
Zdroj: habr.com
