
Ainult jaotatud rakenduste arhitektuuris on iseloomulik:
- Kasutaja sisselogimis- ja paroolide andmete andmebaasi säilitamise puudumine. Juurdepääsu teave säilitatakse ainult kasutajate enda juures, ja nende identiteedi kinnitamine toimub protokolli tasemel.
- Serveri kasutamise vajaduse puudumine. Rakenduse loogika võib toimuda plokiahelavõrgus, kus on võimalik salvestada vajalik hulk andmeid.
On olemas kaks suhteliselt turvalist kasutajate võtmete hoidmise meetodit — riistvaralised rahakotid ja brauserilaiendused. Riistvaralised rahakotid on enamasti äärmiselt turvalised, kuid keerulised kasutada ja kaugel tasuta, samas on brauserilaiendused ideaalne kombinatsioon turvalisusest ja kasutusmugavusest, ja need võivad lõppkasutajatele olla täiesti tasuta.
Arvestades kõike seda, soovisime luua maksimaalselt turvalise laienduse, mis lihtsustab detsentraliseeritud rakenduste arendamist, pakkudes lihtsat API-d tehingute ja allkirjade haldamiseks.
Sellest kogemusest räägime teile allpool.
Artiklis on samm-sammult juhend, kuidas kirjutada brauserilaiendust, koos koodinäidete ja ekraanipiltidega. Kogu koodi leiate siit . Iga kommiteerimine vastab loogiliselt küsitavale osa artiklist.
Brauserilaienduste lühilugu
Brauserilaiendused on eksisteerinud juba piisavalt kaua. Internet Exploreris ilmusid need juba 1999. aastal, Firefoxis 2004. aastal. Siiski ei olnud kaua aega üldiselt standardit laienduste jaoks.
Võib öelda, et see ilmus koos laiendustega Google Chrome'i neljandas versioonis. Loomulikult ei olnud siis mingit spetsifikatsiooni, kuid just Chrome'i API-st sai selle alus: omades suurt osa brauseriturust ja sisseehitatud rakenduste poodi, määras Chrome de facto standardi brauserilaiendustele.
Mozilla'l oli oma standard, kuid nähes Chrome'i laiendite populaarsust, otsustas ettevõte luua ühilduva API. 2015. aastal, Mozilla algatusel, loodi World Wide Web Consortium (W3C) raames spetsiaalne rühm, et töötada platvormidevaheliste laiendite spetsifikatsioonide kallal.
Aluseks võeti juba olemasolev Chrome'i laiendite API. Tööd viidi läbi Microsofti toetusel (Google keeldus standardi arendamises osalemast), ning tulemuseks oli mustand. .
Formaalselt toetavad spetsifikatsiooni Edge, Firefox ja Opera (märgige, et see nimekiri ei sisalda Chrome'i). Kuid tegelikult on standard suuresti ühilduv ka Chrome'iga, kuna see on kirjutatud peamiselt tema laiendite põhjal. Rohkem teavet WebExtensions API kohta saate lugeda. .
Laiendi struktuur
Ainus fail, mis on laiendi jaoks absoluutse vajadus, on manifesto (manifest.json). See on ka laiendi "sisenemispunkt".
Manifest
Spetsifikatsiooni kohaselt on manifesti fail kehtiv JSON-fail. Täiendavat teavet manifesti võtmete kohta, sealhulgas teavet selle kohta, millised võtmed on toetatud millises brauseris, saab vaadata. .
Spetsifikatsioonis puuduvad võtmed võivad olla tähelepanuta jäetud (nii Chrome kui Firefox teatavad vigadest, kuid laiendused töötavad jätkuvalt).
Soovin tähelepanu pöörata mõnele punktile.
- taust — objekt, mis sisaldab järgmisi välju:
- skriptid — massiiv skripte, mis täidetakse taustakontekstis (räägime sellest natuke hiljem);
- leht — skriptide asemel, mis tõukuvad tühjas lehes, saab määrata html sisu. Sellisel juhul jäetakse väljad skript tähelepanuta ja skriptid tuleb lisada sisulehe.
- püsiv — binaarflaag, kui see ei ole määratud, siis brauser "tapab" taustprotsessi, kui arvab, et see ei tee midagi, ja taaskäivitab vajadusel. Vastasel juhul laaditakse leht välja ainult brauseri sulgemisel. Firefoxis ei toetata.
- sisu_skriptid — objektide massiiv, mis võimaldab laadida erinevaid skripte erinevatele veebilehtedele. Iga objekt sisaldab järgmisi olulisi välju:
- võrdub — , mille põhjal määratakse, kas konkreetne sisu skript kaasatakse või mitte.
- js — skriptide loend, mis laaditakse selle vastavuse korral;
- välista_võrded — välistab juurdepääsu
matchURL-id, mis vastavad sellele väljale.
- page_action — on tegelikult objekt, mis vastutab ikooni eest, mis kuvatakse brauseri aadressiribal, ja selle interaktsiooni eest. Samuti võimaldab see kuvada hüpikakna, mille saab määrata oma HTML, CSS ja JS abil.
- default_popup — tee HTML faili juurde, mis sisaldab hüpikliidest, võib sisaldada CSS-i ja JS-i.
- permissions — massiiv, mis haldab laienduse õigusi. Olemas on 3 õiguste tüüpi, mis on üksikasjalikult kirjeldatud
- web_accessible_resources — laienduse ressursid, mida veebileht saab küsida, näiteks pildid, JS, CSS ja HTML failid.
- externally_connectable — siin saab selgelt määratleda teiste laienduste ID-d ja domeene, millest saab ühendust võtta. Domeen võib olla teise taseme või kõrgem. Firefoxis ei tööta.
Täitmise kontekst
Laiendusel on kolm koodi täitmise konteksti, see tähendab, et rakendus koosneb kolmest osast, millel on erinev ligipääs brauseri API-le.
Extension context
Siin on saadaval enamik API-st. Selles kontekstis «elavad»:
- Background page — laienduse «backend» osa. Fail määratakse mapeest võtme «background» kaudu.
- Popup page — popup leht, mis ilmub, kui klõpsate laienduse ikoonil. Manifestis
browser_action->default_popup. - Kohandatud leht — laienduse leht, mis "elab" eraldi vahekaardil
chrome-extension:///customPage.html.
See kontekst eksisteerib sõltumatult brauseri akendest ja vahekaartidest. Background page eksisteerib ainsuses ja töötab alati (erand — event page, kui taustaskript käivitatakse sündmuse põhjal ja "sureb" pärast selle täitmist). Popup page eksisteerib, kui popup aken on avatud ja Kohandatud leht — kuni selle vahekaardiga on ühendus. Selle konteksti kaudu ei ole juurdepääsu teistele vahekaartidele ja nende sisule.
Sisu skripti kontekst
Sisu skriptifail käivitatakse koos iga brauseri vahekaardiga. Sellel on juurdepääs osale laienduse API-st ja veebilehe DOM-puud. Just sisu skriptid vastutavad lehega suhtlemise eest. Laiendused, mis manipuleerivad DOM-puud, teevad seda sisu skriptides – näiteks reklaamiblokid või tõlkijad. Samuti saab sisu skript suhelda lehega läbi standardse postMessage.
Veebilehe kontekst
See on veebileht ise. See ei ole mingil viisil seotud laiendusega ja sellele ei ole ligipääsu muidu, kui manifestis on selgelt märgitud selle lehe domeen (sellest allpool).
Sõnumite vahetamine
Rakenduse erinevad osad peavad omavahel sõnumeid vahetama. Selleks on olemas API runtime.sendMessage sõnumi saatmiseks taust ja tabs.sendMessage sõnumi saatmiseks lehele (sisu skripti, hüpikaknale või veebilehele, kui see on olemas externally_connectable). Allpool on näide, kuidas pöörduda Chrome'i API poole.
// Сообщением может быть любой 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))
}
)Kvaliteetseks suhtlemiseks on võimalik luua ühendusi läbi runtime.connect. Vastuseks saame runtime.Port, kuhu saame avatud oleku ajal saata mistahes arvu sõnumeid. Klientide poolel, näiteks contentscript, näeb see välja nii:
// Опять же 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 või taust:
// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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) {
...
});Samuti on olemas sündmus onDisconnect ja meetod disconnect.
Rakenduse skeem
Loome brauserilaiendi, mis hoiab privaatvõtmeid ja annab ligipääsu avalikule teabele (aadress, avalik võti suhtleb lehega ning võimaldab kolmandatel rakendustel taotleda tehingute allkirjaga.
Rakenduse arendamine
Meie rakendus peab kasutajaga suhtlema ning pakkuma lehe API-d meetodite kutsumiseks (näiteks tehingute allkirjastamiseks). Üksnes sellega contentscript ei saa hakkama, kuna see pääseb ligi ainult DOM-ile, kuid mitte lehe JS-ile. Ühendamine läbi runtime.connect ei ole võimalik, kuna API on vajalik kõigil domeenidel, kuid manifestis saab määrata ainult konkreetseid. Seetõttu näeb skeem välja nii:

Olgu veel üks skript — inpage, mille me lehte süstime. See täidetakse selle kontekstis ja pakub API-d laiendusega töötamiseks.
Algus
Kogu brauserilaiendi kood on saadaval aadressil . Kirjelduse käigus on viidatud commit'idele.
Alustame manifestist:
{
// Nimi ja kirjeldus, versioon. Kõike seda näeb brauseris chrome://extensions/?id=
"name": "Signer",
"description": "Laiendi demo",
"version": "0.0.1",
"manifest_version": 2,
// Skriptid, mis täidavad taustal, neid võib olla mitu
"background": {
"scripts": ["background.js"]
},
// Millist html faili kasutada hüpikakna jaoks
"browser_action": {
"default_title": "Minu Laiend",
"default_popup": "popup.html"
},
// Sisu skriptid.
// Meil on üks objekt: kõikide url-ide üle, mis algavad http või https, käivitame
// sisu skripti konteksti skripti contentscript.js. Käivitame kohe dokumendi saamisel kõigis raamides
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*"
],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
// Ligipääs localStorage'ile ja idle api'le on lubatud
"permissions": [
"storage",
// "unlimitedStorage",
//"clipboardWrite",
"idle"
//"activeTab",
//"webRequest",
//"notifications",
//"tabs"
],
// Siin märgitakse ressurssid, millele veebileht pääseb. Seega saab neid küsida fetch'iga või lihtsalt xhr
"web_accessible_resources": ["inpage.js"]
}Loome tühjad failid background.js, popup.js, inpage.js ja contentscript.js. Lisame popup.html — ja meie rakenduse saab juba Google Chrome'i laadida ning veenduda, et see töötab.
Selle kinnitamiseks saate koodi . Lisaks sellele, mida me tegime, on lingil seadistatud projekti kogumine webpacki abil. Et rakendus brauserisse lisada, tuleb chrome://extensions valida „load unpacked“ ja kaust vastava laiendiga — meie puhul dist.

Nüüd on meie laiend installitud ja töötab. Arendustööriistu erinevate kontekstidena saab käivitada järgmiselt:
popup ->

Sisu skripti konsooli ligipääs toimub lehe enda konsooli kaudu, kus see on käivitatud.
Sõnumite vahetamine
Seega peame looma kaks suhtlusteed: inpage <-> background ja popup <-> background. Loomulikult on võimalik lihtsalt saata sõnumeid pordi kaudu ja leiutada oma protokoll, kuid mulle meeldib rohkem lähenemine, mille nägin avatud koodiga projektis metamask.
See on brauserilaiendus Ethereum võrguga töötamiseks. Selle erinevad rakenduse osad suhtlevad RPC kaudu dnode teegi abil. See võimaldab üsna kiiresti ja mugavalt organiseerida vahetust, kui transportimiseks kasutatakse nodejs voogu (silmas on peetud objekti, mis rakendab sama liidest):
import Dnode from "dnode/browser";
// Selles näites eeldame, et klient kutsub kaugelt tegevusi serveris, kuigi midagi ei takista meil selle kahepoolsetena teha.
// Server
// API, mida soovime pakkuda
const dnode = Dnode({
hello: (cb) => cb(null, "world")
})
// Transport, mille peal dnode töötab. Igasugune nodejs voog. Brauseris on olemas teek 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)
// Klient
const dnodeClient = Dnode() // Kutse ilma argumendita tähendab, et me ei paku API-d teisel pool.
// Prindib konsoolile world
dnodeClient.once('remote', remote => {
remote.hello(((err, value) => console.log(value)))
})Nüüd loome rakenduse klassi. See loob API objekte hüpikmenüü ja veebilehe jaoks ning loob nende jaoks dnode:
import Dnode from 'dnode/browser';
export class SignerApp {
// Tagastab API objekti ui jaoks
popupApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Tagastab API objekti lehe jaoks
pageApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Ühendab popup ui
connectPopup(connectionStream){
const api = this.popupApi();
const dnode = Dnode(api);
connectionStream.pipe(dnode).pipe(connectionStream);
dnode.on('remote', (remote) => {
console.log(remote)
})
}
// Ühendab lehe
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)
})
}
}Siin ja edaspidi kasutame globaalobjekti Chrome asemel extentionApi, mis suhtleb Chrome'iga Google'i brauseris ja browser'iga teistes. See on mõeldud ühilduvuse tagamiseks, kuid selle artikli kontekstis võiks kasutada ka lihtsalt 'chrome.runtime.connect'.
Loome taustaskripti käigus rakenduse eksemplari:
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
const app = new SignerApp();
// onConnect käivitub, kui "protsessid" (contentscript, popup või laienduse leht) on ühendatud
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
// Ühenduse loomisel saab määrata nime, mille järgi me kindlaks teeme, kes meiega ühendust võttis – kas content script või UI
if (processName === 'contentscript'){
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
}else{
app.connectPopup(portStream)
}
}Kuna dnode töötab voogudega ja me saame porta, on vajalik klass-adapter. See on tehtud readable-stream teegi abil, mis rakendab nodejs vooge brauseris:
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()
}
}Nüüd loome ühenduse UI-s:
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(){
// Nagu rakenduse klassis, loome porta, ümbritseme vooga, teeme dnode'i
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)
})
});
// Teeme API objekti konsoolis kättesaadavaks
if (DEV_MODE){
global.background = background;
}
}Seejärel loome ühenduse content scriptis:
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);
}
}Kuna API on vajalik mitte content-scriptis, vaid otse lehel, teeme kaks asja:
- Loome kaks streami. Üks — lehe poole, üle postMessage. Selleks kasutame metamaski loojaid.
runtime.connect. Torustik nende vahel. Nüüd on lehel ühendus taustaprogrammiga. - Sisestame skripti DOMi. Laeme skripti (juurdepääs sellele oli lubatud manifestis) ja loome sildi
scriptselle sisu sees:
import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
setupConnection();
injectScript();
function setupConnection(){
// Stream to background
const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
const backgroundStream = new PortStream(backgroundPort);
// Stream to page
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);
}
}Nüüd loome inpage'is objekt api ja määrame selle global:
import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';
setupInpageApi().catch(console.error);
async function setupInpageApi() {
// Stream to content script
const connectionStream = new PostMessageStream({
name: 'page',
target: 'content',
});
const dnode = Dnode();
connectionStream.pipe(dnode).pipe(connectionStream);
// Get API object
const pageApi = await new Promise(resolve => {
dnode.once('remote', api => {
resolve(api)
})
});
// Access through window
global.SignerApp = pageApi;
}Meil on valmis . Uue lehe ühendamisel taustaga näeme seda:

Tühi API ja origin. Lehe küljelt saame funktsiooni hello kutsuda järgmiselt:

Callback-funktsioonidega töötamine kaasaegses JS-is on vanamoeline, seega kirjutame väikese abistaja, et luua dnode, mis võimaldab edastada objekti API utils-sse.
API objektid näevad nüüd välja sellised:
export class SignerApp {
popupApi() {
return {
hello: async () => "world"
}
}
...
}Kauka objekti saamine järgmiselt:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// Abistajatega vahetame kõik callbackid prometesideks
resolve(transformMethods(cbToPromise, remoteApi))
})
});Ja funktsioonide kutsumine tagastab promisi:

Versioon asünkroonsete funktsioonidega on saadaval .
Üldiselt tundub RPC ja voogude lähenemine piisavalt paindlik: saame kasutada voogude multiplexerimist ja luua erinevate ülesannete jaoks erinevaid API-sid. Põhimõtteliselt saab dnode'i kasutada igal pool, peamine on wrapida transport nodejs voona.
Alternatiiviks on JSON-formaat, mis rakendab protokolli JSON RPC 2. Kuid see töötab konkreetsete transpordimeetoditega (TCP ja HTTP(S)), mis meie puhul ei kehti.
Siseolek ja localStorage
Meil on vaja salvestada rakenduse siseolek — vähemalt allkirjastamise võtmed. Saame suhteliselt hõlpsasti lisada rakendusele oleku ja meetodid selle muutmiseks popup API-s:
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)
}
}
...
} Backgroundis ümbritseme kõik funktsiooni ja salvestame rakenduse objekti window-s, et saaksime sellega konsoolist töötada:
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)
}
}
}Lisame konsoolist kasutajaliidesesse mõned võtmed ja vaatame, mis meie olekuga juhtus:

Riik tuleb muuta püsivaks, et klahvid ei kaoks taaskäivitamisel.
Salvestame localStorage'i, kirjutades iga muutmise korral üle. Tulevikus on sellele ka UI jaoks vajalik juurdepääs, ning tahaksime ka muutustele allkirjastuda. Seetõttu on mugav luua jälgitav salvestus (observable storage) ja allkirjastuda selle muutustele.
Kasutame raamatukogu mobx (). Valik langes sellele, kuna selle kasutamisega polnud varem kokkupuudet, kuid väga soovisin seda uurida.
Lisame algse oleku initsialiseerimise ja teeme salvestuse nähtavaks:
import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
export class SignerApp {
constructor(initState = {}) {
// Väliselt jääb salvestus samaks objektiks, kuid nüüd on kõik selle väljad proxy, mis jälgivad juurdepääsu neile
this.store = observable.object({
keys: initState.keys || [],
});
}
// Meetodid, mis muudavad nähtavat, on soovitatav kaunistada dekoratsiooniga
@action
addKey(key) {
this.store.keys.push(key)
}
@action
removeKey(index) {
this.store.keys.splice(index, 1)
}
...
}„Kapoti all“ asendas mobx kõik salvestuse väljad proxy'ga ja püüab kõik nendele tehtud päringud kinni. Neile päringutele saab ka allkirjastuda.
Edasi kasutan sageli terminit “muutmise” kohta, kuigi see ei ole päris korrektne. Mobx jälgib tegelikult juurdepääsu väljadele. Kasutatakse getter'eid ja setter'eid, mida loob raamatukogu.
Action dekaatorid teenivad kahte eesmärki:
- Range režiimi all lippu enforceActions kasutades keelab mobx staatuse otsese muutmise. Heaks tonks peetakse töötamist just range režiimis.
- Isegi kui funktsioon muudab staatust mitu korda – näiteks muutame mitut välja mitmes koodireas, – teavitatakse observer'eid ainult pärast selle lõpetamist. See on eriti oluline frontend'is, kus tarbetud staatuse värskendused viivad üleliigsete elementide renderdamiseni. Meie juhul ei ole kumbki eriti aktuaalne, kuid järgime parimaid tavasid. Dekoraatoreid on tavaks lisada kõikidele funktsioonidele, mis muudavad jälgitavate väljade staatust.
Taustal lisame staatuse initsiialiseerimise ja salvestamise localStorage'isse:
import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Abivahendid. Kirjutavad/loevad objekti localStorage-s JSON-stringina võtme 'store' järgi
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;
}
// Seisundi püsivuse seadistamine
// Reaction-i tulemus omistatakse muutujale, et tellimust oleks võimalik tühistada. Me ei vaja seda, jäetud näitena
const localStorageReaction = reaction(
() => toJS(app.store), // Andmete valimise funktsioon
saveState // Funktsioon, mida kutsutakse esile, kui andmed, mida valija tagastab, muutuvad
);
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)
}
}
}Siin on huvitav reaction-funktsioon. Sellel on kaks argumenti:
- Andmete valija.
- Käitleja, mis kutsutakse esile nende andmete jaoks iga kord, kui need muutuvad.
Erinevalt redux'ist, kus me saame oleku otse argumentidena, jälgib mobx, millistele täheldatavatele me selektoris ligi pääseme, ja ainult nende muutmisel kutsub ta esile töötleja.
On oluline mõista, kuidas mobx määrab, millistele täheldatavatele me tellime. Kui ma kirjutaksin selektori nii() => app.store, siis reactionit ei kutsutaks kunagi, kuna ise boonus ei ole jälgitav, vaid ainult tema väljad on.
Kui ma kirjutaksin nii () => app.store.keys, siis jälle ei juhtuks midagi, kuna massiivi elementide lisamisel/ kustutamisel link sellele ei muutu.
Mobx täidab esmakordselt selektori funktsiooni ja jälgib ainult neid täheldatavaid, millele me ligipääsu saime. See toimub läbi proxy getter'ite. Seetõttu on siin kasutatud sisseehitatud funktsiooni toJS. See tagastab uue objekti, kus kõik proxy-d on asendatud originaalväljadega. Töö käigus loetakse kõik objekti väljad – seega aktiveeritakse getter'id.
Konsolis lisame popup'isse uuesti mõned võtmed. Seekord jõudsid need ka localStorage'i:

Taustalehe uuendamisel jääb teave paika.
Kogu rakenduse kood kuni selle hetkeni on saadaval vaatamiseks .
Privaats võtmete turvaline salvestamine
Privaatsete võtmete salvestamine avatud kujul on ohtlik: alati on võimalus, et teie süsteemi häkitakse ja keegi pääseb teie arvutisse. Seetõttu salvestame localStorage'is võtmed krüpteeritud parooliga.
Turvalisuse suurendamiseks lisame rakendusele lukustatud oleku, milles võtmetele ei ole üldse ligipääsu. Automaatne üleminek lukustatud olekusse toimub ajastuse tõttu.
Mobx võimaldab salvestada vaid minimaalse andmekogumi, kõik muu arvutatakse automaatselt selle põhjal. Need on nn arvutatud omadused. Neid saab võrrelda andmebaaside vaadetega:
import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Utiliidid ohutute kehtivate stringide turvaliseks kodeerimiseks. Kasutatakse crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";
export class SignerApp {
constructor(initState = {}) {
this.store = observable.object({
// Salvestame parooli ja krüpteeritud võtmed. Kui parool on null, siis rakendus on lukustatud
password: null,
vault: initState.vault,
// Getterid arvutatavate väljade jaoks. Saame võrrelda seda andmebaasi vaatega.
get locked() {
return this.password == null
},
get keys() {
return this.locked ?
undefined :
SignerApp._decryptVault(this.vault, this.password)
},
get initialized() {
return this.vault !== undefined
}
})
}
// Tühja hoiustamise initsialiseerimine uue parooliga
@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
)
}
... // koodi ühendamine ja api
// privaatne
_checkPassword(password) {
SignerApp._decryptVault(this.store.vault, password);
}
_checkLocked() {
if (this.store.locked) {
throw new Error('Rakendus on lukustatud')
}
}
// Meetodid hoiustamise kodeerimiseks/dekodeerimiseks
static _encryptVault(obj, pass) {
const jsonString = JSON.stringify(obj)
return encrypt(jsonString, pass)
}
static _decryptVault(str, pass) {
if (str === undefined) {
throw new Error('Hoiustamine pole initsialiseeritud')
}
try {
const jsonString = decrypt(str, pass)
return JSON.parse(jsonString)
} catch (e) {
throw new Error('Vale parool')
}
}
}Nüüd salvestame ainult krüpteeritud võtmed ja parooli. Kõik muu arvutatakse. Üleminek lukustatud olekusse toimub parooli eemaldamise kaudu olekust. Avalikus API-s on lisatud meetod, mis võimaldab talletamist initsialiseerida.
Krüpteerimiseks on kirjutatud :
import CryptoJS from 'crypto-js'
// Kasutatakse parooli murdmise keerukuse suurendamiseks. Iga parooli variandi puhul peab ründaja tegema 5000 hash'i
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)
}Brauseril on idle API, mille kaudu saab tellida sündmusi — oleku muutusi. Oleku tüüp võib olla vastavalt idle, active ja locked. Idle seadistamiseks saab määrata taimeri, samas kui lukustatud olek kehtestatakse, kui operatsioonisüsteem ise lukustub. Samuti muudame selektorit, et salvestada localStorage'i:
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;
}
// Nüüd kutsume selgelt välja välja, millele ligipääs toimub, reaction toimib normaalselt
reaction(
() => ({
vault: app.store.vault
}),
saveState
);
// Aegunud timeout, kui sündmus aktiveerub
extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
// Kui kasutaja lukustab ekraani või on antud ajavahemikus passiivne, lukustame rakenduse
extensionApi.idle.onStateChanged.addListener(state => {
if (['locked', 'idle'].indexOf(state) > -1) {
app.lock()
}
});
// Ühendame teiste kontekstidega
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)
}
}
}Kood enne seda sammu asub .
Tehingud
Nii, me oleme jõudnud kõige olulisemani: tehingute loomine ja allkirjastamine plokiahelas. Kasutame WAVES plokiahela ja raamatukogu .
Alustuseks lisame olekusse massiivi sõnumitest, mis tuleb allkirjastada, seejärel - uue sõnumi lisamise, allkirjastamise kinnitamise ja tagasilükkamise meetodid:
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) {
// Iga sõnumi jaoks loome metainformatsiooni id, staatuse, loomise aja jne. kohta.
const message = observable.object({
id: uuid(), // Identifikaator, kasutades uuid
origin, // Originaali näitame hiljem liideses
data, //
status: 'new', // Staatused on neli: new, signed, rejected ja failed
timestamp: Date.now()
});
console.log(`new message: ${JSON.stringify(message, null, 2)}`);
this.store.messages.push(message);
// Tagastame lubaduse, mille sees mobx jälgib sõnumi muutusi. Niipea, kui staatus muutub, lahendame selle
return new Promise((resolve, reject) => {
reaction(
() => message.status, //Jälgime sõnumi staatust
(status, reaction) => { // Teine argument on viide reaktsioonile, et saaksime seda hävitada kutsumise sees
switch (status) {
case 'signed':
resolve(message.data);
break;
case 'rejected':
reject(new Error('Kasutaja tagandas sõnumi'));
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(`Sõnumit id:${id} ei leitud`);
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(`Sõnumit id:${id} ei leitud`);
message.status = 'rejected'
}
...
}Uue sõnumi saabumisel lisame sellele metaandmed ja teeme observable ja lisame store.messages.
Kui seda käsitsi ei teha, siis mobx teeb selle ise sõnumite massiivi lisamisel. Kuid ta loob uue objekti, millele meil ei ole viidet, ja seda on vajalik järgmise sammu jaoks. observable Seejärel tagastame promissi, mis lahendatakse, kui sõnumi staatus muutub. Reaktsioon jälgib staatust ja tappab end ise, kui staatus muutub.
Meetodite kood
approve reject ja on väga lihtne: me lihtsalt muudame sõnumi staatust, allkirjastades selle eelnevalt, kui see on vajalik. Approve ja reject viime API UI-sse, newMessage — API lehele:
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) } }... }
Nüüd proovime tehingut allkirjastada laiendiga:Üldiselt on kõik valmis, jääb

lisada lihtne UI .
UI
Liides vajab rakenduse olekule juurdepääsu. UI poolel teeme observable oleku ja lisame API-sse funktsiooni, mis seda olekut muudab. Lisame observable API objektile, mille saime background'ilt:
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() {
// Ühendume pordiga, loome sellest voolu
const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
const connectionStream = new PortStream(backgroundPort);
// Loome tühja observable background'i oleku jaoks
let backgroundState = observable.object({});
const api = {
// Anname background'ile funktsiooni, mis uuendab observable'i
updateState: async state => {
Object.assign(backgroundState, state)
}
};
// Loome RPC objekti
const dnode = setupDnode(connectionStream, api);
const background = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
resolve(transformMethods(cbToPromise, remoteApi))
})
});
// Lisame background'ile observable oleku
background.state = backgroundState;
if (DEV_MODE) {
global.background = background;
}
// Käivitame liidese
await initApp(background)
}
Lõpuks käivitame rakenduse kasutajaliidese renderdamise. See on react-rakendus. Taustobjekt edastatakse lihtsalt propside kaudu. Õigesti oleks loomulikult luua eraldi teenus meetodite ja state'i jaoks, kuid selle artikli kontekstis on see piisav:
import {render} from 'react-dom'
import App from './App'
import React from "react";
// Initsialiseerime rakenduse taustobjektiga propside kujul
export async function initApp(background){
render(
,
document.getElementById('app-content')
);
}
Mobx'i abil on väga lihtne renderdada, kui andmed muutuvad. Me lihtsalt rakendame dekoratori observer pakendist komponendile, ja renderdamine kutsutakse automaatselt esile, kui mis tahes observable, millele komponent viitab, muutub. Ei ole vaja mingit mapStateToProps või connect'i nagu redux'is. Kõik töötab kohe „kastist välja“:
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 // Kui komponent on dekoreeritud, kutsutakse render meetod automaatselt, kui muudetakse observables, millele see viitab
export default class App extends Component {
// Loomulikult on õige, et lehe renderdusloogika paigutatakse marsruutimise juurde ning mitte kasutada sisemisi ternaarseid operaatorid,
// ning siduda observables ja meetodid taustaga otse nende komponentidega, mis neid kasutavad
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()}>Lukusta rakendus</button>}
{initialized && <button onclick="{()" > deleteVault()}>Kustuta kõik võtmed ja initsialiseeri</button>}
</div>
</Fragment>
}
}Ülejäänud komponente saab vaadata koodis .
Nüüd tuleb rakenduse klassis luua UI jaoks state'i selektor ja teavitada UI-d selle muutumisel. Selleks lisame meetodi getState ja reaction, mis kutsub 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) => {
// Loome reaktsiooni oleku muutustele, mis kutsub välja kaugprotseduuri ja uuendab olekut UI protsessis
const updateStateReaction = reaction(
() => this.getState(),
(state) => remote.updateState(state),
// Kolmandana saab edastada parameetreid. fireImmediatly tähendab, et reaktsioon käivitub esmakordselt kohe.
// See on vajalik, et saada esmane olek. Delay võimaldab seadistada debounce
{fireImmediately: true, delay: 500}
);
// Eemaldame tellimuse kliendi ühenduse katkestamisel
dnode.once('end', () => updateStateReaction.dispose())
})
}
...
}Objekti saamisel remote loome reaction oleku muutustele, mis kutsub välja funktsiooni UI poolel.
Viimane puudutus — lisame uute sõnumite kuvamise laienduse ikoonile:
function setupApp() {
...
// Reaktsioon badge'i teksti seadmisel.
reaction(
() => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
text => extensionApi.browserAction.setBadgeText({text}),
{fireImmediately: true}
);
...
}Nii et rakendus on valmis. Veebilehed saavad taotleda tehingute allkirjastamist:


Kood on saadaval siin .
Kokkuvõte
Kui olete artikli lõpuni lugenud, kuid teil on küsimusi, saate need esitada . Seal leiate iga määratud sammu kohta commit'id.
Ja kui teid huvitab tõelise laiendi koodi vaatamine, leiate selle .
Kood, repertuaar ja töö kirjeldus autorilt
Allikas: habr.com
