
Spre deosebire de arhitectura „client-server” obișnuită, aplicațiile descentralizate se caracterizează prin:
- Lipsa necesității de a stoca o bază de date cu numele de utilizator și parolele utilizatorilor. Informațiile de acces sunt stocate exclusiv de utilizatori, iar confirmarea validității se face la nivelul protocolului.
- Lipsa necesității de a folosi un server. Logica aplicației poate fi executată în rețeaua blockchain, unde este posibil și stocarea necesarului de date.
Există 2 tipuri relativ sigure de stocare pentru cheile utilizatorilor — portofele hardware și extensii de browser. Portofelele hardware sunt în general foarte sigure, însă complicate de utilizat și departe de a fi gratuite, pe când extensiile de browser reprezintă combinația ideală între securitate și ușurință de utilizare, putând fi chiar complet gratuite pentru utilizatorii finali.
Ținând cont de toate acestea, am dorit să creăm o extensie cât mai sigură, care să simplifice dezvoltarea aplicațiilor descentralizate, oferind un API simplu pentru lucrul cu tranzacțiile și semnăturile.
Despre această experiență vă vom povesti mai jos.
În articol va fi prezentată o instrucțiune pas cu pas despre cum să scrieți o extensie de browser, cu exemple de cod și capturi de ecran. Întregul cod poate fi găsit în . Fiecare commit corespunde logic unei secțiuni din acest articol.
O scurtă istorie a extensiilor de browser
Extensiile de browser există de ceva vreme. În Internet Explorer, ele au apărut încă din anul 1999, iar în Firefox — în 2004. Cu toate acestea, a durat mult până la apariția unui standard unic pentru extensii.
Se poate spune că acesta a apărut odată cu extensiile în versiunea a patra a Google Chrome. Desigur, nu exista nicio specificație la vremea respectivă, dar anume API-ul Chrome a devenit baza acestuia: câștigând o mare parte din piața browserelor și având un magazin de aplicații încorporat, Chrome a stabilit de facto standardul pentru extensiile de browser.
Mozilla a avut propriul său standard, dar, având în vedere popularitatea extensiilor pentru Chrome, compania a decis să creeze un API compatibil. În 2015, la inițiativa Mozilla, în cadrul World Wide Web Consortium (W3C) a fost creat un grup special pentru a lucra la specificațiile extensiilor cross-browser.
S-a folosit un API existent pentru extensiile Chrome ca bază. Lucrările au fost realizate cu sprijinul Microsoft (Google a refuzat să participe la dezvoltarea standardului), rezultând un draft .
Formal, specificația este susținută de Edge, Firefox și Opera (observați că Chrome nu este inclus în această listă). Însă, de fapt, standardul este în mare parte compatibil și cu Chrome, deoarece a fost scris pe baza extensiilor sale. Mai multe informații despre WebExtensions API pot fi citite .
Structura extensiei
Singurul fișier necesar pentru extensie este manifestul (manifest.json). Acesta este și „punctul de intrare” în extensie.
Manifest
Conform specificației, fișierul manifest este un fișier JSON valid. O descriere completă a cheilor manifestului, cu informații despre ce chei sunt acceptate în fiecare browser, poate fi consultată .
Cheile care nu sunt în specificație „pot” fi ignorate (atât Chrome, cât și Firefox semnalează erori, dar extensiile continuă să funcționeze).
Aș vrea să subliniez câteva aspecte.
- background — un obiect care include următoarele câmpuri:
- scripts — un array de scripturi care vor fi executate în contextul background (vom discuta despre acest lucru puțin mai târziu);
- page — în locul scripturilor care vor fi executate pe o pagină goală, se poate specifica HTML cu conținut. În acest caz, câmpul script va fi ignorat, iar scripturile trebuie inserate în pagina cu conținut;
- persistent — un flag binar; dacă nu este specificat, browserul va „închide” procesul background atunci când consideră că nu face nimic, și îl va repornii la nevoie. În caz contrar, pagina va fi descărcată doar la închiderea browserului. Nu este suportat în Firefox.
- content_scripts — un array de obiecte, care permite încărcarea diferitelor scripturi pe diferite pagini web. Fiecare obiect conține următoarele câmpuri importante:
- matches — , care determină dacă un anumit script content va fi inclus sau nu.
- js — o listă de scripturi care vor fi încărcate în acest meci;
- exclude_matches — excluderi din câmpul
matchURL care satisfac această condiție.
- page_action — este de fapt un obiect care este responsabil pentru pictograma care apare lângă bara de adrese din browser, și interacțiunea cu aceasta. Permite de asemenea să se afișeze o fereastră popup, care este definită prin HTML, CSS și JS proprii.
- default_popup — calea către un fișier HTML cu interfața popup, poate conține CSS și JS.
- permissions — un array pentru gestionarea drepturilor extensiei. Există 3 tipuri de drepturi, care sunt descrise detaliat
- web_accessible_resources — resursele extensiei pe care pagina web le poate solicita, cum ar fi imagini, fișiere JS, CSS, HTML.
- externally_connectable — aici se pot specifica în mod explicit ID-urile altor extensii și domeniile paginilor web din care se poate conecta. Domeniul poate fi de nivel secundar și superior. Nu funcționează în Firefox.
Contextul de execuție
Extensia are trei contexte de execuție a codului, adică aplicația este formată din trei părți cu un nivel diferit de acces la API-ul browserului.
Contextul Extensiei
Aici este disponibilă cea mai mare parte a API-ului. În acest context „trăiesc”:
- Pagina de fundal — partea "backend" a extensiei. Fișierul este specificat în manifest prin cheia "background}".
- Pagina pop-up — pagina pop-up care apare atunci când se face clic pe pictograma extensiei. În manifest
browser_action->default_popup. - Pagina personalizată — pagina extensiei care „trăiește” într-o tabă separată de tipul
chrome-extension:///customPage.html.
Acest context există independent de feronțele și tab-urile browserului. Pagina de fundal există într-un singur exemplar și funcționează întotdeauna (excepția este pagina de evenimente, când scriptul de fundal este activat printr-un eveniment și „moare” după executarea acestuia). Pagina pop-up există atunci când este deschisă o fereastră pop-up, iar Pagina personalizată — în timp ce este deschisă o tabă cu aceasta. Nu există acces la alte tab-uri și conținutul acestora din acest context.
Contextul script-ului de conținut
Fișierul script-ului de conținut se activează împreună cu fiecare tabă a browserului. Acesta are acces la o parte din API-ul extensiei și la DOM-ul paginii web. Exact script-urile de conținut sunt responsabile pentru interacțiunea cu pagina. Extensiile care manipulează DOM-ul fac acest lucru în script-urile de conținut – de exemplu, blocatoarele de reclame sau traducătorii. De asemenea, scriptul de conținut poate comunica cu pagina prin intermediul standardului postMessage.
Contextul paginii web
Aceasta este, propriu-zis, pagina web în sine. Nu are nicio legătură cu extensia și nu are acces la aceasta, cu excepția cazurilor în care în manifest nu este specificat în mod explicit domeniul acestei pagini (despre aceasta – mai jos).
Schimbul de mesaje
Diferitele părți ale aplicației trebuie să schimbe mesaje între ele. Pentru aceasta există API-ul runtime.sendMessage pentru a trimite un mesaj background și tabs.sendMessage pentru a trimite un mesaj paginii (script-ului de conținut, pop-up-ului sau paginii web, dacă există externally_connectable). Mai jos este un exemplu comentând API-ul 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))
}
)Pentru o comunicare eficientă, pot fi create conexiuni prin runtime.connect. Ca răspuns, vom primi runtime.Port, în care, atâta timp cât este deschis, pot fi trimise un număr nelimitat de mesaje. Pe partea clientului, de exemplu, contentscript, arată astfel:
// Опять же 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 sau 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) {
...
});Există, de asemenea, un eveniment onDisconnect și metoda deconectare.
Schema aplicației
Să creăm o extensie de browser care stochează chei private, oferă acces la informații publice (adresă, cheie publică comunică cu pagina și permite aplicațiilor externe să solicite semnarea tranzacțiilor.
Dezvoltarea aplicației
Aplicația noastră trebuie să interacționeze atât cu utilizatorul, cât și să ofere paginii o API pentru apelarea metodelor (de exemplu, pentru semnarea tranzacțiilor). Nu putem funcționa doar cu contentscript deoarece acesta are acces doar la DOM, dar nu și la JS al paginii. Nu putem conecta prin runtime.connect pentru că API-ul trebuie să fie disponibil pe toate domeniile, iar în manifest putem specifica doar unele anume. Așadar, schema va arăta astfel:

Va exista un alt script — inpage, pe care îl vom injecta în pagină. Acesta se va executa în contextul său și va oferi o API pentru a lucra cu extensia.
Început
Întregul cod al extensiei de browser este disponibil pe . În descriere vor fi linkuri la commituri.
Să începem cu manifestul:
{
// Nume și descriere, versiune. Toate acestea vor fi vizibile în browser la chrome://extensions/?id=<id extensie>
"name": "Signer",
"description": "Demo extensie",
"version": "0.0.1",
"manifest_version": 2,
// Scripturi care vor rula în background, pot fi mai multe
"background": {
"scripts": ["background.js"]
},
// Ce html să folosească pentru popup
"browser_action": {
"default_title": "My Extension",
"default_popup": "popup.html"
},
// Scripturi de conținut.
// Avem un singur obiect: pentru toate URL-urile care încep cu http sau https, rulăm
// contextul contenscript cu scriptul contentscript.js. Se va lansa imediat la primirea documentului pentru toate iframe-urile
"content_scripts": [
{
"matches": [
"http:/*/*",
"https:/*/*"
],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
// Accesul la localStorage și idle API este permis
"permissions": [
"storage",
// "unlimitedStorage",
//"clipboardWrite",
"idle"
//"activeTab",
//"webRequest",
//"notifications",
//"tabs"
],
// Aici se specifică resursele la care pagina web va avea acces. Adică, acestea pot fi solicitate cu fetche'ul sau pur și simplu un xhr
"web_accessible_resources": ["inpage.js"]
}Creăm fișierele goale background.js, popup.js, inpage.js și contentscript.js. Adăugăm popup.html — și aplicația noastră poate fi deja încărcată în Google Chrome pentru a verifica dacă funcționează.
Pentru a verifica acest lucru, putem lua codul . În plus față de ceea ce am realizat, la link este configurat un build al proiectului folosind webpack. Pentru a adăuga aplicația în browser, în chrome://extensions trebuie să selectăm load unpacked și folderul corespunzător extensiei — în cazul nostru dist.

Acum extensia noastră este instalată și funcționează. Instrumentele pentru dezvoltatori pentru diferite contexte pot fi lansate astfel:
popup ->

Accesul la consola scriptului de conținut se face prin consola paginii pe care este lansat.
Schimbul de mesaje
Astfel, trebuie să stabilim două canale de comunicare: inpage <-> background și popup <-> background. Bineînțeles, putem trimite mesaje la port și să inventăm propriul nostru protocol, dar prefer metoda pe care am observat-o în proiectul open-source metamask.
Aceasta este o extensie de browser pentru a lucra cu rețeaua Ethereum. În aceasta, diferitele părți ale aplicației comunică prin RPC folosind biblioteca dnode. Aceasta permite organizarea rapidă și convenabilă a schimbului, dacă îi oferim ca transport un stream nodejs (referindu-se la obiectul care implementează aceeași interfață):
import Dnode from "dnode/browser";
// În acest exemplu, presupunem că clientul apelează de la distanță funcțiile pe server, deși nimic nu ne împiedică să facem acest lucru bidirecțional
// Server
// API-ul pe care dorim să-l oferim
const dnode = Dnode({
hello: (cb) => cb(null, "world")
})
// Transportul, pe care dnode va funcționa. Orice stream nodejs. În browser există biblioteca 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)
// Client
const dnodeClient = Dnode() // Apel fără argument înseamnă că nu oferim API de cealaltă parte
// Va afișa în consolă world
dnodeClient.once('remote', remote => {
remote.hello(((err, value) => console.log(value)))
})Acum vom crea clasa aplicației. Aceasta va crea obiecte API pentru popup și pagină web, precum și va crea dnode pentru acestea:
import Dnode from 'dnode/browser';
export class SignerApp {
// Returnează obiectul API pentru ui
popupApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Returnează obiectul API pentru pagină
pageApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Conectează popup ui
connectPopup(connectionStream){
const api = this.popupApi();
const dnode = Dnode(api);
connectionStream.pipe(dnode).pipe(connectionStream);
dnode.on('remote', (remote) => {
console.log(remote)
})
}
// Conectează pagina
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)
})
}
}Aici și mai departe, în locul obiectului global Chrome, folosim extentionApi, care comunică cu Chrome în browserul Google și cu browser în alte cazuri. Acest lucru se face pentru a asigura compatibilitate între browsere, dar în cadrul acestui articol s-ar fi putut folosi și pur și simplu 'chrome.runtime.connect'.
Să creăm o instanță a aplicației în scriptul de fundal:
import {extensionApi} from './utils/extensionApi';
import {PortStream} from './utils/PortStream';
import {SignerApp} from './SignerApp';
const app = new SignerApp();
// onConnect este declanșat la conectarea 'proceselor' (contentscript, popup sau pagina extensiei)
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
// La stabilirea conexiunii, putem specifica un nume, pe baza căruia determinăm cine s-a conectat, content script sau ui
if (processName === 'contentscript'){
const origin = remotePort.sender.url;
app.connectPage(portStream, origin);
}else{
app.connectPopup(portStream);
}
}Din moment ce dnode lucrează cu fluxuri, iar noi primim un port, este necesară o clasă-adaptator. Aceasta este creată cu ajutorul bibliotecii readable-stream, care implementează fluxuri nodejs în browser:
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();
}
}Acum creăm conexiunea în 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(){
// De asemenea, ca și în clasa aplicației creăm portul, îl împachetăm în stream, facem 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)
})
});
// Facem obiectul API disponibil din consola
if (DEV_MODE){
global.background = background;
}
}Apoi, creăm o conexiune în scriptul de conținut:
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);
}
}Deoarece avem nevoie de API nu în scriptul de conținut, ci direct pe pagină, facem două lucruri:
- Creăm două stream-uri. Unul — către pagină, deasupra postMessage. Pentru aceasta folosim de la creatorii metamask. Al doilea stream — către background deasupra portului obținut de
runtime.connect. Le unim. Acum pagina va avea un stream către background. - Injectăm scriptul în DOM. Descărcăm scriptul (accesul la el a fost permis în manifest) și creăm o etichetă
scriptcu conținutul său în interior:
import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
setupConnection();
injectScript();
function setupConnection(){
// Stream către background
const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
const backgroundStream = new PortStream(backgroundPort);
// Stream către pagină
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);
}
}Acum creăm obiectul api în inpage și îl facem global:
import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';
setupInpageApi().catch(console.error);
async function setupInpageApi() {
// Stream to the content script
const connectionStream = new PostMessageStream({
name: 'page',
target: 'content',
});
const dnode = Dnode();
connectionStream.pipe(dnode).pipe(connectionStream);
// Getting the API object
const pageApi = await new Promise(resolve => {
dnode.once('remote', api => {
resolve(api)
})
});
// Access via window
global.SignerApp = pageApi;
}Suntem gata . La conectarea unei noi pagini la background, putem observa acest lucru:

API gol și origin. Pe partea paginii, putem apela funcția hello astfel:

Lucrul cu funcțiile callback în JS-ul modern este depășit, așadar vom scrie un mic helper pentru a crea dnode, care permite transmiterea într-un obiect API în utils.
Obiectele API vor arăta acum astfel:
export class SignerApp {
popupApi() {
return {
hello: async () => "world"
}
}
...
}Obținerea obiectului de la remote astfel:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// Folosind utilitarii, schimbăm toate callback-urile în promisiuni
resolve(transformMethods(cbToPromise, remoteApi))
})
});Și apelarea funcțiilor returnează o promisiune:

Versiunea cu funcții asincrone este disponibilă .
În general, abordarea cu RPC și stream-uri pare suficient de flexibilă: putem folosi multiplexarea stream-ului și crea mai multe API-uri diferite pentru sarcini diferite. Practic, dnode poate fi folosit oriunde, important este să înfășurăm transportul sub forma unui stream nodejs.
O alternativă este formatul JSON, care implementează protocolul JSON RPC 2. Totuși, acesta funcționează cu transporturi specifice (TCP și HTTP(S)), ceea ce în cazul nostru nu este aplicabil.
Starea internă și localStorage
Va trebui să stocăm starea internă a aplicației — cel puțin, cheile pentru semnare. Putem adăuga destul de ușor starea aplicației și metodele pentru modificarea acesteia în API-ul popup:
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)
}
}
...
} În background, vom înfășura totul într-o funcție și vom scrie obiectul aplicației în window, astfel încât să putem lucra cu el din consolă:
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)
}
}
}Să adăugăm câțiva chei din UI console și să vedem ce s-a întâmplat cu starea:

Starea trebuie făcută persistentă, astfel încât cheile să nu se piardă la repornire.
O vom stoca în localStorage, suprascriind la fiecare modificare. Ulterior, accesul la aceasta va fi, de asemenea, necesar pentru UI, și ne dorim să ne abonăm și la modificări. Din acest motiv, va fi convenabil să facem un depozit observabil (observable storage) și să ne abonăm la modificările sale.
Vom folosi biblioteca mobx (). Am ales-o deoarece nu am avut ocazia să lucrez cu ea și am vrut foarte mult să o învăț.
Să adăugăm inițializarea stării și să facem store-ul observabil:
import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
export class SignerApp {
constructor(initState = {}) {
\/\/ Extern, store-ul va rămâne același obiect, doar că acum toate câmpurile sale au devenit proxy-uri care urmăresc accesul la ele
this.store = observable.object({
keys: initState.keys || [],
});
}
\/\/ Metodele care schimbă observable se consideră a fi învelite în decorator
@action
addKey(key) {
this.store.keys.push(key)
}
@action
removeKey(index) {
this.store.keys.splice(index, 1)
}
...
}„Sub capotă“, mobx a înlocuit toate câmpurile store-ului cu proxy-uri și interceptă toate apelurile la acestea. Aceste apeluri vor putea fi urmărite.
În continuare, voi folosi frecvent termenul „la modificare”, deși nu este tocmai corect. Mobx urmărește exact accesul la câmpuri. Se folosesc getterii și setterii obiectelor proxy create de bibliotecă.
Decoratorii action servesc două scopuri:
- În modul strict cu flag-ul enforceActions mobx interzice schimbarea stării direct. Este considerat un lucru bun să lucrezi tocmai în modul strict.
- Chiar dacă funcția modifică starea de mai multe ori – de exemplu, schimbăm mai multe câmpuri în mai multe rânduri de cod – observerii sunt notificați doar la finalizarea acesteia. Acest lucru este deosebit de important pentru front-end, unde actualizările inutile ale stării conduc la redări necorespunzătoare ale elementelor. În cazul nostru, nici primul, nici al doilea nu sunt foarte relevante, totuși vom urma cele mai bune practici. Decoratorii sunt aplicate tuturor funcțiilor care modifică starea câmpurilor observabile.
În background, vom adăuga inițializarea și salvarea stării în localStorage:
import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Metode auxiliari. Salvează/lueză obiectul în/din localStorage sub formă de șir JSON prin cheie '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;
}
// Configurarea persistenței stării
// Rezultatul reacției este atribuit unei variabile, pentru a putea anula abonamentul. Nu avem nevoie de asta, l-am lăsat ca exemplu
const localStorageReaction = reaction(
() => toJS(app.store), // Funcția de selecție a datelor
saveState // Funcția care va fi apelată la modificarea datelor pe care le returnează selectorul
);
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)
}
}
}Funcția reaction este interesantă aici. Are doi parametri:
- Selector de date.
- Handlerul care va fi apelat cu aceste date de fiecare dată când se schimbă.
Spre deosebire de redux, unde obținem în mod explicit starea ca argument, mobx își amintește de la ce observable ne adresăm în selector și îl invocă pe handler doar când acestea se schimbă.
Este important să înțelegem cum anume mobx decide de care observable ne abonăm. Dacă aș fi scris selectorul așa() => app.store, atunci reacția nu va fi niciodată apelată, deoarece depozitul în sine nu este observabil; doar câmpurile sale sunt observabile.
Dacă aș fi scris așa () => app.store.keys, atunci din nou nu s-ar întâmpla nimic, deoarece, la adăugarea/ștergerea elementelor din array, referința la acesta nu se va schimba.
Mobx execută funcția selector pentru prima dată și urmărește doar acele observable la care am avut acces. Acest lucru se face prin intermediul getterelor proxy. De aceea, aici este folosită funcția încorporată toJS. Aceasta returnează un nou obiect în care toate proxile sunt înlocuite cu câmpurile originale. În timpul execuției, citește toate câmpurile obiectului – prin urmare, se activează getterele.
În consola popup, vom adăuga din nou câteva chei. De data aceasta, acestea au ajuns și în localStorage:

La reîncărcarea paginii background, informația rămâne la locul ei.
Întregul cod al aplicației până în acest moment poate fi vizualizat .
Stocarea sigură a cheilor private
A stoca cheile private în mod deschis este nesigur: există întotdeauna riscul să fiți piratat, să vi se acceseze computerul etc. De aceea, în localStorage vom stoca cheile într-o formă criptată cu parolă.
Pentru o mai mare securitate, vom adăuga aplicației un stăt locked, în care accesul la chei nu va fi deloc permis. Vom traduce automat extensia în starea locked după un timp prestabilit.
Mobx permite stocarea doar a setului minim de date, iar restul sunt calculate automat pe baza acestora. Acestea sunt ceea ce se numește computed properties. Ele pot fi comparate cu view-uri în bazele de date:
import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Utilitare pentru criptarea sigură a șirurilor. Folosește crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";
export class SignerApp {
constructor(initState = {}) {
this.store = observable.object({
// Păstrăm parola și cheile criptate. Dacă parola este null - aplicația este blocată
password: null,
vault: initState.vault,
// Gettere pentru câmpuri calculabile. Se poate face o analogie cu view în baza de date.
get locked() {
return this.password == null
},
get keys() {
return this.locked ?
undefined :
SignerApp._decryptVault(this.vault, this.password)
},
get initialized() {
return this.vault !== undefined
}
})
}
// Inițializarea unui depozit gol cu o nouă parolă
@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
)
}
... // cod de conectare și api
// privat
_checkPassword(password) {
SignerApp._decryptVault(this.store.vault, password);
}
_checkLocked() {
if (this.store.locked) {
throw new Error('Aplicația este blocată')
}
}
// Metode pentru criptarea/decodarea depozitului
static _encryptVault(obj, pass) {
const jsonString = JSON.stringify(obj)
return encrypt(jsonString, pass)
}
static _decryptVault(str, pass) {
if (str === undefined) {
throw new Error('Depozitul nu este inițializat')
}
try {
const jsonString = decrypt(str, pass)
return JSON.parse(jsonString)
} catch (e) {
throw new Error('Parola greșită')
}
}
}Acum stocăm doar chei criptate și parola. Tot restul este calculat. Trecerea în starea locked se face prin eliminarea parolei din stare. În API-ul public a apărut o metodă pentru inițializarea depozitului.
Pentru criptare au fost scrise :
import CryptoJS from 'crypto-js'
// Folosit pentru a complica descoperirea parolei prin forță brută. Fiecare variantă de parolă va necesita 5000 de hash-uri de către atacator
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)
}Browserul dispune de o API idle, prin care se poate abona la evenimentul — modificări de stare. Starea, în consecință, poate fi inactiv, activ și blocat. Pentru idle, se poate seta un timeout, iar locked se stabilește atunci când sistemul de operare este blocat. De asemenea, vom schimba selectorul pentru a salva în 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;
}
// Acum apelăm în mod explicit câmpul la care se va avea acces, reacția va funcționa corect
reaction(
() => ({
vault: app.store.vault
}),
saveState
);
// Timeout de inactivitate, când evenimentul va fi declanșat
extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
// Dacă utilizatorul a blocat ecranul sau a fost inactiv timp de intervalul specificat, blocăm aplicația
extensionApi.idle.onStateChanged.addListener(state => {
if (['locked', 'idle'].indexOf(state) > -1) {
app.lock()
}
});
// Conectare la alte contexte
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)
}
}
}Codul până la acest pas se află .
Tranzacții
Așadar, am ajuns la cel mai important aspect: crearea și semnarea tranzacțiilor în blockchain. Vom folosi blockchain-ul WAVES și biblioteca .
Pentru început, să adăugăm în stare un array de mesaje care trebuie semnate, apoi — metode pentru adăugarea unui nou mesaj, confirmarea semnăturii și respingerea:
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) {
// Pentru fiecare mesaj creăm metadate cu id, status, timpul de creare etc.
const message = observable.object({
id: uuid(), // Identificator, folosind uuid
origin, // Origin-ul îl vom afișa ulterior în interfață
data, //
status: 'new', // Vor fi patru statusuri: new, signed, rejected și failed
timestamp: Date.now()
});
console.log(`new message: ${JSON.stringify(message, null, 2)}`);
this.store.messages.push(message);
// Returnăm o promisiune în care mobx monitorizează schimbările mesajului. De îndată ce statusul se schimbă, îl vom rezolva
return new Promise((resolve, reject) => {
reaction(
() => message.status, // Vom observa statusul mesajului
(status, reaction) => { // al doilea argument este o referință la reactie, pentru a o putea distruge în interiorul apelului
switch (status) {
case 'signed':
resolve(message.data);
break;
case 'rejected':
reject(new Error('Utilizatorul a respins mesajul'));
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(`Nu există msg cu 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(`Nu există msg cu id:${id}`);
message.status = 'rejected'
}
...
}Când primim un mesaj nou, adăugăm metadate în el, facem observable și adăugăm în store.messages.
Dacă nu facem asta manual, mobx o va face singur atunci când adăugăm în array-ul messages. Totuși, va crea un nou obiect, la care nu vom avea o referință, iar aceasta va fi necesară pentru următorul pas. observable Apoi întoarcem o promisiune care se rezolvă la schimbarea statusului mesajului. Statusul este urmărit de reaction, care se va "îndepărta" singur la schimbarea statusului.
Codul metodelor
reject approve și este foarte simplu: pur și simplu schimbăm statusul mesajului, semnându-l anterior, dacă este necesar. foarte simplu: schimbăm statutul mesajului, semnându-l anterior, dacă este necesar.
Aprobați și respingeți le aducem în API UI, newMessage — în API-ul paginilor:
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)
}
}
...
}Acum vom încerca să semnăm o tranzacție cu extensia:

În general, totul este gata, rămâne .
UI
Interfeței îi trebuie acces la starea aplicației. Pe partea UI vom face observable starea și vom adăuga o funcție în API care va schimba această stare. Vom adăuga observable în obiectul API, obținut de la 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() {
// Ne conectăm la port, creăm un stream din acesta
const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
const connectionStream = new PortStream(backgroundPort);
// Creăm un observable gol pentru starea background-ului
let backgroundState = observable.object({});
const api = {
// Dăm background-ului o funcție care va actualiza observable-ul
updateState: async state => {
Object.assign(backgroundState, state)
}
};
// Creăm un obiect RPC
const dnode = setupDnode(connectionStream, api);
const background = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
resolve(transformMethods(cbToPromise, remoteApi))
})
});
// Adăugăm în background observable cu starea
background.state = backgroundState;
if (DEV_MODE) {
global.background = background;
}
// Lanzăm interfața
await initApp(background)
}
La final, lansăm redarea interfeței aplicației. Aceasta este o aplicație react. Obiectul background este pur și simplu transmis prin props. Corect ar fi să facem un serviciu separat pentru metode și un store pentru stare, dar în cadrul acestui articol este suficient:
import {render} from 'react-dom'
import App from '.\/App'
import React from "react";
// Inițializăm aplicația cu obiectul background ca props
export async function initApp(background){
render(
,
document.getElementById('app-content')
);
}
Cu mobx este foarte simplu să lansezi redarea la schimbarea datelor. Pur și simplu atașăm decoratorul observer din pachetul pe component, și redarea va fi apelată automat la modificarea oricărui observable la care se referă componenta. Nu sunt necesare mapStateToProps sau connect, ca în redux. Totul funcționează imediat „din cutie”:
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 // Component with this decorator will automatically call the render method if the observables it references change
export default class App extends Component {
// It is correct to extract the page rendering logic into routing and not use nested ternary operators,
// and to bind observables and methods of background directly to the components that use them
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()}>Blochează aplicația</button>}
{initialized && <button onclick="{()" > deleteVault()}>Șterge toate cheile și inițializează</button>}
</div>
</Fragment>
}
}Celelalte componente pot fi vizualizate în cod .
Acum, în clasa aplicației, este necesar să realizăm un selector de stare pentru UI și, la modificarea acesteia, să notificăm UI-ul. Pentru aceasta, vom adăuga metoda getState și reaction, care apelează 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) => {
// Creăm reaction pentru modificările stării, care va apela procedura remote și va actualiza starea în procesul ui
const updateStateReaction = reaction(
() => this.getState(),
(state) => remote.updateState(state),
// Al treilea argument poate fi utilizat pentru a transmite parametrii. fireImmediatly înseamnă că reaction se va executa prima dată imediat.
// Aceasta este necesară pentru a obține starea inițială. Delay permite stabilirea unui debounce
{fireImmediately: true, delay: 500}
);
// Vom elimina abonamentul la deconectarea clientului
dnode.once('end', () => updateStateReaction.dispose())
})
}
...
}La primirea obiectului remote se creează reaction pe modificarea stării, care apelează funcția de partea UI.
Ultimul detaliu — să adăugăm afișarea mesajelor noi pe pictograma extensiei:
function setupApp() {
...
// Reaction pentru setarea textului badge-ului.
reaction(
() => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
text => extensionApi.browserAction.setBadgeText({text}),
{fireImmediately: true}
);
...
}Deci, aplicația este gata. Pagini web pot solicita semnătura tranzacțiilor:


Codul este disponibil la această .
Concluzie
Dacă ai citit articolul până la capăt, dar ai întrebări, le poți adresa în . Acolo vei găsi și commit-uri pentru fiecare pas menționat.
Și dacă ești interesat să vezi codul unei extensii adevărate, îl vei putea găsi aici .
Cod, repo și descrierea lucrării de la
Sursa: habr.com
