Na rozdíl od běžné architektury „klient-server“ se decentralizované aplikace vyznačují:
Není potřeba ukládat databázi s uživatelskými přihlašovacími údaji a hesly. Přístupové informace jsou ukládány výhradně samotnými uživateli a potvrzení jejich pravosti probíhá na úrovni protokolu.
Není třeba používat server. Aplikační logiku lze provádět na blockchainové síti, kde je možné uložit potřebné množství dat.
K dispozici jsou 2 relativně bezpečná úložiště pro uživatelské klíče – hardwarové peněženky a rozšíření prohlížeče. Hardwarové peněženky jsou většinou extrémně bezpečné, ale obtížně se používají a zdaleka nejsou zdarma, ale rozšíření prohlížeče jsou dokonalou kombinací zabezpečení a snadného použití a pro koncové uživatele mohou být také zcela zdarma.
Vzhledem k tomu všemu jsme chtěli vytvořit nejbezpečnější rozšíření, které zjednoduší vývoj decentralizovaných aplikací poskytnutím jednoduchého API pro práci s transakcemi a podpisy.
O této zkušenosti vám povíme níže.
Článek bude obsahovat podrobné pokyny, jak napsat rozšíření prohlížeče, s příklady kódu a snímky obrazovky. Celý kód najdete v úložišť. Každé potvrzení logicky odpovídá části tohoto článku.
Stručná historie rozšíření prohlížeče
Rozšíření pro prohlížeče existují již dlouhou dobu. V Internet Exploreru se objevily již v roce 1999, ve Firefoxu v roce 2004. Po velmi dlouhou dobu však neexistoval jediný standard pro rozšíření.
Dá se říci, že se objevil spolu s rozšířeními ve čtvrté verzi Google Chrome. Tehdy samozřejmě neexistovala žádná specifikace, ale jejím základem se stalo rozhraní API pro Chrome: Chrome dobyl většinu trhu s prohlížeči a měl vestavěný obchod s aplikacemi a ve skutečnosti nastavil standard pro rozšíření prohlížeče.
Mozilla měla svůj vlastní standard, ale vzhledem k popularitě rozšíření Chrome se společnost rozhodla vytvořit kompatibilní API. V roce 2015 byla z iniciativy Mozilly vytvořena speciální skupina v rámci World Wide Web Consortium (W3C), která pracovala na specifikacích rozšíření pro různé prohlížeče.
Jako základ byla vzata stávající rozšíření API pro Chrome. Práce byla provedena s podporou společnosti Microsoft (Google se odmítl podílet na vývoji standardu) a v důsledku toho se objevil návrh Specifikace.
Formálně je specifikace podporována Edge, Firefox a Opera (všimněte si, že Chrome není na tomto seznamu). Ale ve skutečnosti je standard do značné míry kompatibilní s Chrome, protože je ve skutečnosti napsán na základě jeho rozšíření. Můžete si přečíst více o rozhraní WebExtensions API zde.
Struktura rozšíření
Jediný soubor, který je vyžadován pro rozšíření, je manifest (manifest.json). Je to také „vstupní bod“ do expanze.
Manifest
Podle specifikace je soubor manifestu platný soubor JSON. Úplný popis klíčů manifestu s informacemi o tom, které klíče jsou podporovány ve kterém prohlížeči, lze zobrazit zde.
Klíče, které nejsou ve specifikaci „mohou“ být ignorovány (Chrome i Firefox hlásí chyby, ale rozšíření nadále fungují).
A rád bych upozornil na některé body.
pozadí — objekt, který obsahuje následující pole:
skripty — pole skriptů, které budou spuštěny v kontextu na pozadí (o tom si povíme trochu později);
strana - místo skriptů, které se budou spouštět na prázdné stránce, můžete zadat html s obsahem. V tomto případě bude pole skriptu ignorováno a skripty bude nutné vložit na stránku s obsahem;
vytrvalý — binární příznak, pokud není zadán, prohlížeč „zabije“ proces na pozadí, když se domnívá, že nic nedělá, a v případě potřeby jej restartuje. V opačném případě bude stránka uvolněna pouze při zavření prohlížeče. Není podporováno ve Firefoxu.
content_scripts — pole objektů, které vám umožní načíst různé skripty na různé webové stránky. Každý objekt obsahuje následující důležitá pole:
zápasy - adresa URL vzoru, která určuje, zda bude zahrnut konkrétní skript obsahu či nikoli.
js — seznam skriptů, které budou načteny do této shody;
vyloučit_shody - vylučuje z oboru match Adresy URL, které odpovídají tomuto poli.
page_action - je vlastně objekt, který je zodpovědný za ikonu, která se zobrazuje vedle adresního řádku v prohlížeči a interakci s ní. Umožňuje také zobrazit vyskakovací okno, které je definováno pomocí vlastního HTML, CSS a JS.
default_popup — cesta k souboru HTML s vyskakovacím rozhraním, může obsahovat CSS a JS.
oprávnění — pole pro správu práv rozšíření. Existují 3 druhy práv, které jsou podrobně popsány zde
web_accessible_resources — rozšiřující zdroje, které si webová stránka může vyžádat, například obrázky, JS, CSS, HTML soubory.
externě_připojitelné — zde můžete explicitně zadat ID dalších rozšíření a domén webových stránek, ze kterých se můžete připojit. Doména může být druhé úrovně nebo vyšší. Nefunguje ve Firefoxu.
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ůznými úrovněmi přístupu k rozhraní API prohlížeče.
Kontext rozšíření
Většina API je k dispozici zde. V tomto kontextu „žijí“:
Stránka na pozadí — „backend“ část rozšíření. Soubor je specifikován v manifestu pomocí klíče „pozadí“.
Vyskakovací stránka — vyskakovací stránka, která se zobrazí po kliknutí na ikonu rozšíření. V manifestu browser_action -> default_popup.
Vlastní stránka — stránka rozšíření, „živá“ na samostatné kartě zobrazení chrome-extension://<id_расширения>/customPage.html.
Tento kontext existuje nezávisle na oknech a kartách prohlížeče. Stránka na pozadí existuje v jediné kopii a vždy funguje (výjimkou je stránka události, kdy je skript na pozadí spuštěn událostí a po jejím provedení „umře“). Vyskakovací stránka existuje, když je otevřené vyskakovací okno, a Vlastní stránka — když je karta s ním otevřená. Z tohoto kontextu není přístup k dalším kartám a jejich obsahu.
Kontext skriptu obsahu
Soubor skriptu obsahu se spustí spolu s každou kartou prohlížeče. Má přístup k části API rozšíření a ke stromu DOM webové stránky. Právě obsahové skripty jsou zodpovědné za interakci se stránkou. Rozšíření, která manipulují se stromem DOM, to dělají ve skriptech obsahu – například blokovače reklam nebo překladače. Obsahový skript také může komunikovat se stránkou prostřednictvím standardu postMessage.
Kontext webové stránky
Toto je samotná webová stránka. Nemá nic společného s rozšířením a nemá tam přístup, s výjimkou případů, kdy doména této stránky není výslovně uvedena v manifestu (více o tom níže).
Zprávy
Různé části aplikace si musí vyměňovat zprávy. Existuje na to API runtime.sendMessage odeslat zprávu background и tabs.sendMessage pro odeslání zprávy na stránku (skript obsahu, vyskakovací okno nebo webovou stránku, je-li k dispozici externally_connectable). Níže je uveden příklad přístupu k rozhraní 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 plnou komunikaci můžete vytvářet spojení prostřednictvím runtime.connect. Jako odpověď obdržíme runtime.Port, na který, když je otevřený, můžete posílat libovolný počet zpráv. Na straně klienta je např. contentscript, vypadá to 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 pozadí:
// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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) {
...
});
Je zde také akce onDisconnect a způsob disconnect.
Schéma aplikace
Udělejme rozšíření prohlížeče, které uchovává soukromé klíče, poskytuje přístup k veřejným informacím (adresa, veřejný klíč komunikuje se stránkou a umožňuje aplikacím třetích stran vyžadovat podpis pro transakce.
Vývoj aplikací
Naše aplikace musí jak komunikovat s uživatelem, tak poskytovat stránce API pro volání metod (například pro podepisování transakcí). Vystačíte si s jedním contentscript nebude fungovat, protože má přístup pouze k DOM, ale ne k JS stránky. Připojte se přes runtime.connect nemůžeme, protože rozhraní API je potřeba ve všech doménách a v manifestu lze zadat pouze konkrétní. V důsledku toho bude diagram vypadat takto:
Bude další scénář - inpage, který vložíme do stránky. Poběží ve svém kontextu a poskytne API pro práci s rozšířením.
začátek
Veškerý kód rozšíření prohlížeče je k dispozici na adrese GitHub. Během popisu budou odkazy na commity.
Začněme manifestem:
{
// Имя и описание, версия. Все это будет видно в браузере в 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"]
}
Vytvořte prázdné background.js, popup.js, inpage.js a contentscript.js. Přidáme popup.html – a naši aplikaci již lze načíst do Google Chrome a zajistit, aby fungovala.
Chcete-li to ověřit, můžete si vzít kód proto. Kromě toho, co jsme udělali, odkaz nakonfiguroval sestavení projektu pomocí webpacku. Chcete-li přidat aplikaci do prohlížeče, musíte v chrome://extensions vybrat načíst rozbalený a složku s odpovídající příponou - v našem případě dist.
Nyní je naše rozšíření nainstalováno a funguje. Vývojářské nástroje můžete spustit pro různé kontexty následovně:
vyskakovací okno ->
Přístup ke konzole skriptu obsahu se provádí prostřednictvím konzole samotné stránky, na které je spuštěn.
Zprávy
Potřebujeme tedy vytvořit dva komunikační kanály: inpage <-> background a popup <-> background. Můžete samozřejmě jen posílat zprávy na port a vymýšlet si svůj vlastní protokol, ale preferuji přístup, který jsem viděl v projektu metamask open source.
Jedná se o rozšíření prohlížeče pro práci se sítí Ethereum. V něm různé části aplikace komunikují přes RPC pomocí knihovny dnode. Umožňuje vám organizovat výměnu poměrně rychle a pohodlně, pokud jí poskytnete stream nodejs jako transport (což znamená objekt, který implementuje stejné rozhraní):
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)))
})
Nyní vytvoříme aplikační třídu. Vytvoří objekty API pro vyskakovací okno a webovou stránku a vytvoří pro ně 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)
})
}
}
Zde a níže místo globálního objektu Chrome používáme rozšířeníApi, které přistupuje k Chrome v prohlížeči Google a prohlížeči v ostatních. To se provádí pro kompatibilitu mezi různými prohlížeči, ale pro účely tohoto článku lze jednoduše použít 'chrome.runtime.connect'.
Vytvořme instanci aplikace ve skriptu na pozadí:
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)
}
}
Protože dnode pracuje se streamy a my přijímáme port, je potřeba třída adaptéru. Je vytvořen pomocí knihovny readable-stream, která implementuje nodejs streamy v prohlížeči:
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;
}
}
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 {
// 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);
}
}
Protože potřebujeme rozhraní API ne ve skriptu obsahu, ale přímo na stránce, děláme dvě věci:
Vytváříme dva proudy. Jedna – směrem ke stránce, v horní části zprávy. K tomu používáme toto tento balíček od tvůrců metamasky. Druhý proud je na pozadí přes port přijatý z runtime.connect. Pojďme si je koupit. Nyní bude mít stránka stream na pozadí.
Vložte skript do DOM. Stáhněte si skript (přístup k němu byl povolen v manifestu) a vytvořte značku script s jeho obsahem uvnitř:
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);
}
}
Nyní vytvoříme objekt api v inpage a nastavíme jej na globální:
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;
}
Prázdné API a původ. Na straně stránky můžeme zavolat funkci hello takto:
Práce s funkcemi zpětného volání v moderním JS je špatné chování, takže si pojďme napsat malého pomocníka pro vytvoření dnade, který vám umožní předat objekt API utils.
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// С помощью утилит меняем все callback на promise
resolve(transformMethods(cbToPromise, remoteApi))
})
});
Celkově se přístup RPC a stream jeví docela flexibilní: můžeme použít multiplexování páry a vytvořit několik různých API pro různé úkoly. V zásadě lze dnode použít kdekoli, hlavní je obalit transport ve formě nodejs streamu.
Alternativou je formát JSON, který implementuje protokol JSON RPC 2. Ten však pracuje se specifickými transporty (TCP a HTTP(S)), což v našem případě není použitelné.
Vnitřní stav a místní úložiště
Budeme potřebovat uložit vnitřní stav aplikace – alespoň podpisové klíče. Do aplikace můžeme poměrně snadno přidat stav a metody pro jeho změnu ve vyskakovacím API:
Na pozadí vše zabalíme do funkce a zapíšeme objekt aplikace do okna, 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)
}
}
}
Pojďme přidat několik klíčů z konzoly uživatelského rozhraní a uvidíme, co se stane se stavem:
Stav musí být trvalý, aby se klíče při restartu neztratily.
Uložíme jej do localStorage a při každé změně jej přepíšeme. Následně k němu bude nutný přístup i pro UI a také bych se rád přihlásil ke změnám. Na základě toho bude vhodné vytvořit pozorovatelné úložiště a přihlásit se k odběru jeho změn.
Použijeme knihovnu mobx (https://github.com/mobxjs/mobx). Volba padla na to, protože jsem s tím nemusel pracovat, ale opravdu jsem to chtěl studovat.
Přidáme inicializaci počátečního stavu a učiníme obchod pozorovatelným:
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 pokličkou," mobx nahradil všechna pole obchodů proxy a zachycuje všechny hovory na ně. K odběru těchto zpráv bude možné se přihlásit.
Níže budu často používat termín „při změně“, i když to není zcela správné. Mobx sleduje přístup k polím. Používají se gettry a settery proxy objektů, které knihovna vytvoří.
Akční dekoratéři slouží ke dvěma účelům:
V přísném režimu s příznakem forceActions mobx zakazuje přímou změnu stavu. Za dobrou praxi se považuje pracovat za přísných podmínek.
I když funkce změní stav několikrát - například změníme několik polí v několika řádcích kódu - pozorovatelé jsou upozorněni pouze na dokončení. To je důležité zejména pro frontend, kde zbytečné aktualizace stavu vedou ke zbytečnému vykreslování prvků. V našem případě není první ani druhý zvlášť relevantní, ale budeme se řídit osvědčenými postupy. Ke všem funkcím, které mění stav sledovaných polí, je zvykem připojovat dekorátory.
Na pozadí přidáme inicializaci a uložení stavu do 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)
}
}
}
Zajímavá je zde reakční funkce. Má dva argumenty:
Výběr dat.
Obslužná rutina, která bude volána s těmito daty pokaždé, když se změní.
Na rozdíl od redux, kde explicitně přijímáme stav jako argument, si mobx pamatuje, ke kterým pozorovatelným objektům máme přístup uvnitř selektoru, a zavolá handler pouze tehdy, když se změní.
Je důležité přesně porozumět tomu, jak mobx rozhoduje o tom, které pozorovatelné položky odebíráme. Kdybych napsal selektor v kódu takto() => app.store, pak nebude reakce nikdy vyvolána, protože úložiště samotné není pozorovatelné, pouze jeho pole jsou.
Kdybych to napsal takhle () => app.store.keys, pak by se opět nic nestalo, protože při přidávání/odebírání prvků pole se odkaz na něj nezmění.
Mobx funguje jako selektor poprvé a sleduje pouze ty pozorovatelné, ke kterým jsme přistupovali. To se provádí pomocí proxy getterů. Proto je zde použita vestavěná funkce toJS. Vrátí nový objekt se všemi proxy nahrazenými původními poli. Během provádění čte všechna pole objektu - proto se spouštějí getry.
Ve vyskakovací konzoli opět přidáme několik kláves. Tentokrát skončili také v localStorage:
Když se stránka na pozadí znovu načte, informace zůstanou na svém místě.
Všechny kódy aplikace až do tohoto okamžiku lze zobrazit zde.
Bezpečné uložení soukromých klíčů
Ukládání soukromých klíčů v čistém textu není bezpečné: vždy existuje šance, že budete napadeni, získáte přístup k počítači atd. Proto v localStorage uložíme klíče v heslem zašifrované podobě.
Pro větší bezpečnost do aplikace přidáme uzamčený stav, ve kterém nebude přístup ke klíčům vůbec. Pobočku automaticky převedeme do uzamčeného stavu z důvodu časového limitu.
Mobx umožňuje uložit jen minimální sadu dat a zbytek se na základě toho automaticky vypočítá. Jedná se o takzvané počítané vlastnosti. Lze je přirovnat k pohledům v databázích:
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')
}
}
}
Nyní ukládáme pouze zašifrované klíče a heslo. Vše ostatní se počítá. Převod do uzamčeného stavu provedeme odstraněním hesla ze stavu. Veřejné API má nyní metodu pro inicializaci úložiště.
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)
}
Prohlížeč má idle API, přes které se můžete přihlásit k odběru událostí – změn stavu. Stát tedy může být idle, active и locked. Pro nečinnost můžete nastavit časový limit a zámek je nastaven, když je blokován samotný OS. Změníme také volič pro ukládání na 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)
}
}
}
Takže se dostáváme k tomu nejdůležitějšímu: vytváření a podepisování transakcí na blockchainu. Využijeme blockchain a knihovnu WAVES vlny-transakce.
Nejprve přidejte do stavu pole zpráv, které je třeba podepsat, a poté přidejte 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) {
// Для каждого сообщения создаем метаданные с 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'
}
...
}
Když obdržíme novou zprávu, přidáme k ní metadata, udělejte observable a přidat do store.messages.
Pokud ne observable ručně, pak to mobx udělá sám při přidávání zpráv do pole. Vytvoří však nový objekt, na který nebudeme mít referenci, ale budeme jej potřebovat pro další krok.
Dále vrátíme příslib, který se vyřeší, když se změní stav zprávy. Stav je sledován reakcí, která se při změně stavu „zabije“.
Kód metody approve и reject velmi jednoduché: jednoduše změníme stav zprávy po jejím podepsání v případě potřeby.
Schválit a zamítnout jsme dali do rozhraní UI API, newMessage do rozhraní API stránky:
Rozhraní potřebuje přístup ke stavu aplikace. Na straně uživatelského rozhraní to uděláme observable stavu a přidejte do API funkci, která tento stav změní. Přidejme observable na objekt API přijatý z pozadí:
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 konci začneme renderovat rozhraní aplikace. Toto je aplikace pro reakce. Objekt pozadí je jednoduše předán pomocí rekvizit. Bylo by samozřejmě správné vytvořit samostatnou službu pro metody a úložiště pro stát, ale pro účely tohoto článku to stačí:
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')
);
}
S mobx je velmi snadné začít vykreslovat při změně dat. Dekoratér pozorovatele jednoduše zavěsíme z balení mobx-reagovat na komponentě a render bude automaticky volán, když se změní jakékoli pozorovatelné, na které komponenta odkazuje. Nepotřebujete žádné mapStateToProps ani připojení jako v reduxu. Vše funguje hned po vybalení:
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>
}
}
Nyní ve třídě aplikace musíte vytvořit selektor stavu pro uživatelské rozhraní a upozornit uživatelské rozhraní, když se změní. Chcete-li to provést, přidejte metodu getState и reactionpovolání 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())
})
}
...
}
Při příjmu předmětu remote je vytvořen reaction změnit stav, který volá funkci na straně uživatelského rozhraní.
Posledním dotykem je přidání zobrazení nových zpráv na ikonu rozšíření:
function setupApp() {
...
// Reaction на выставление текста беджа.
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 vyžadovat podpis pro transakce:
Pokud jste článek dočetli až do konce, ale stále máte otázky, můžete se jich zeptat na úložiště s rozšířením. Tam také najdete commity pro každý určený krok.
A pokud máte zájem podívat se na kód skutečného rozšíření, najdete toto zde.