
Erinevalt levinud «klient-server» arhitektuurist on detsentraliseeritud rakenduste iseloomulikud tunnused:
- Puudub vajadus säilitada kasutajate sisselogimise ja paroolide andmebaasi. Juurdepääsu teave hoitakse ainult kasutajate enda juures ning nende autentimise kontroll toimub protokolli tasandil.
- Puudub vajadus serveri järele. Rakenduse loogika võib toimuda plokiahelas, kus on võimalik hoida vajalikke andmeid.
Kasutajate võtmete jaoks on olemas kaks suhteliselt turvalist hoiust — riistvarakotid ja brauseri laiendused. Enamik riistvarakoote on maksimaalselt ohutud, kuid neid on keeruline kasutada ja need ei ole sugugi tasuta. Brauseri laiendused pakuvad aga ideaalset kombinatsiooni turvalisusest ja kasutusmugavusest ning võivad olla täiesti tasuta lõppkasutajatele.
Kuna kõik see arvesse võetakse, soovisime luua maksimaalselt turvalise laienduse, mis lihtsustab detsentraliseeritud rakenduste arendamist, pakkudes lihtsat API-d tehingute ja allkirjade jaoks.
Sellest kogemusest räägime teile allpool.
Artiklis on samm-sammult juhend, kuidas kirjutada brauseri laiendust, koos koodinäidete ja ekraanipiltidega. Kogu koodi leiate . Iga commit vastab loogiliselt väljaande lõigule.
Lühike ajalugu brauseri laiendustest
Brauseri laiendusi on olemas juba piisavalt kaua. Internet Explorers ilmusid nad juba 1999. aastal, Firefoxis — 2004. aastal. Siiski ei olnud kaua aega ühegi ühtse standardi omaduse.
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: saavutades suure osa brauseri turust ja olles sisse ehitatud rakenduste poega, dikteeris Chrome sisuliselt brauseri laienduste standardi.
Mozillal oli oma standard, kuid nähes Chrome'i laienduste populaarsust, otsustas ettevõte luua ühilduva API. 2015. aastal Mozillalt algatatud W3C (World Wide Web Consortium) raames loodi spetsiaalne grupp, et töötada välja ülekantavate laienduste spetsifikatsioonide üle.
Aluseks on juba olemasolev API laiendustele Chrome'is. Töö toimus Microsofti toetusel (Google keeldus standardi väljatöötamisest osalemast) ja tulemuseks on mustand. .
Formaalselt toetavad spetsifikatsiooni Edge, Firefox ja Opera (pange tähele, et Chrome'i see nimekiri ei sisalda). Kuid tegelikult on standard suuresti ühilduv ka Chrome'iga, kuna see on tegelikult kirjutatud tema laienduste põhjal. Rohkem teavet WebExtensions API kohta saate lugeda. .
Laienduse struktuur
Ainus fail, mis on laienduse jaoks kindlasti vajalik — manifest (manifest.json). See on ka laienduse "sissepääs".
Manifest
Spetsifikatsiooni kohaselt on manifesti fail kehtiv JSON fail. Terve dokumentatsioon manifeistide võtmete kohta koos informatsiooniga selle kohta, millised võtmed mida brauserites toetavad, on saadaval. .
Võtmed, mida spetsifikatsioonis ei ole, "võivad" olla tähelepanuta jäetud (nii Chrome kui ka Firefox teavitavad vigadest, kuid laiendused jätkavad töötamist).
Ja ma tahaksin tähelepanu juhtida mõningatele punktidele.
- background — objekt, mis sisaldab järgmisi välju:
- scripts — massiiv skripte, mis töödeldakse taustakontekstis (räägime sellest natuke hiljem);
- veebileht — skriptide asemel, mis töötavad tühjal lehel, saab määrata HTML-i sisuga. Sel juhul väli script jäetakse tähelepanuta ja skriptid tuleb lisada lehele koos sisuga;
- persistent — binaarflag, kui see pole määratud, tapab brauser taustprotsessi, kui arvab, et see ei tee midagi, ja taaskäivitab selle vajadusel. Vastasel juhul laaditakse leht maha ainult brauseri sulgemisel. Firefoxis ei toetata.
- content_scripts — massiiv objekte, mis võimaldab laadida erinevaid skripte erinevatele veebilehtedele. Iga objekt sisaldab järgmisi olulisi välju:
- matches — , mille alusel määratakse, kas konkreetne content script aktiveeritakse või mitte.
- js — skriptide loend, mis laaditakse antud vaste korral;
- exclude_matches — välistab väljastpoolt
matchURL-id, mis vastavad sellele väljale.
- page_action — on tegelikult objekt, mis vastutab ikooni eest, mis kuvatakse brauseri aadressiribal, ning suhtlemise eest sellega. Samuti võimaldab see kuvada popup aken, mille määravad omad HTML, CSS ja JS.
- default_popup — tee HTML-failini, millel on popup-liides, võib sisaldada CSS-i ja JS-i.
- permissions — massiiv õiguste haldamiseks. On olemas 3 tüüpi õigusi, mis on üksikasjalikult kirjas
- web_accessible_resources — laienduse ressursid, mida veebileht võib küsida, näiteks pildid, JS-failid, CSS, HTML.
- externally_connectable — siin saab selgelt määratleda teiste laienduste ID-d ja veebilehtede domeene, millest saab ühenduda. Domeen võib olla teisel tasemel või kõrgem. Firefoxis ei toimi.
Täitev kontekst
Laiendusel on kolm koodiefekti konteksti, st rakendus koosneb kolmest osast, millel on erinev juurdepääs brauseri API-le.
Laienduse kontekst
Siin on saadaval enamik API-st. Selles kontekstis „elavad”:
- Taustaleht — laienduse „taust” osa. Fail on määratud manifestis võtme kaudu „background”.
- Poppaleht — poppaleht, mis ilmub laienduse ikoonile klõpsamisel. Manifestis
browser_action->default_popup. - Kohandatud leht — laienduse leht, mis „elab” eraldi vahekaartide juures
chrome-extension:///customPage.html.
See kontekst eksisteerib sõltumatult brauseri akendest ja vahelehtedest. Taustaleht eksisteerib ainulaadsena ja töötab alati (välja arvatud juhul, kui tegemist on event page, kus taustaskript käivitatakse sündmuse järgi ja „sureb” pärast selle täitmist). Poppaleht eksisteerib, kui poppaken on avatud, ja Kohandatud leht — seni, kuni on avatud vahekaart, millel see asub. Juhtimine teistele vahekaartidele ja nende sisule ei ole sellest kontekstist võimalik.
Sisu skripti kontekst
Sisu skripti fail käivitatakse koos iga brauseri vahekaartiga. Tal on juurdepääs osadele laienduse API-st ja veebilehe DOM-puu kaudu. Just sisu skriptid vastutavad lehega suhtlemise eest. Laiendused, mis manipuleerivad DOM-puuga, teevad seda sisu skripti kaudu – näiteks reklaamiblokeerijad või tõlkijad. Samuti võib sisu skript suhelda lehega standardse postMessage.
Veebilehe kontekst
See on ise veebileht. Laiendusega ei ole see mingit seost ja sellele ei ole juurdepääsu, välja arvatud juhul, kui manifestis on selgelt määratletud 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 background ja tabs.sendMessage sõnumi saatmiseks lehele (sisu skripti, poppalehe või veebilehe, kui see on olemas) externally_connectable. Allpool on näide, kui 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))
}
)Täielikuks suhtlemiseks on võimalik luua ühendusi läbi runtime.connect. Vastuseks saame runtime.Port, kuhu, kuni see on avatud, saab saata lõputult sõnumeid. Kliendipool, 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 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) {
...
});Samuti on olemas event onDisconnect ja meetod disconnect.
Rakenduse skeem
Teeme brauserilaiendi, mis salvestab privaatvõtmed, pakub juurdepääsu avalikule teabele (aadress, avalik võti suhtleb lehega ja võimaldab kolmandatel rakendustel taotleda tehingute allkirjastamist.
Rakenduse arendus
Meie rakendus peab suhtlema nii kasutajaga kui ka pakkuma lehelt API-d meetodite kutsumiseks (näiteks tehingute allkirjastamiseks). Ühega ainult contentscript ei piisa, kuna tal on juurdepääs ainult DOM-ile, kuid mitte lehe JS-ile. Ühendust luua läbi runtime.connect me ei saa, kuna API-d vajatakse kõigil domeenidel, kuid manifestis saab määrata ainult konkreetseid. Lõpptulemusena skeem näeb välja nii:

Tuleb veel üks skript — inpage, mille me lehte süstime. See täidab ennast tema kontekstis ja pakub API-d laienduse jaoks.
Algus
Kogu brauserilaiendi kood on saadaval aadressil . Kirjeldamise protsessi käigus on viidatud commit'idele.
Alustame manifestist:
{
// Nimi ja kirjeldus, versioon. Kõik see on nähtav brauseris aadressil chrome://extensions/?id=<laiendi id>
"name": "Signer",
"description": "Extension demo",
"version": "0.0.1",
"manifest_version": 2,
// Skriptid, mis täidetakse taustal, neid võib olla mitu
"background": {
"scripts": ["background.js"]
},
// Millist html'i kasutada popup'i jaoks
"browser_action": {
"default_title": "My Extension",
"default_popup": "popup.html"
},
// Sisu skriptid.
// Meil on üks objekt: kõigi url-ide jaoks, mis algavad http või https-ga, käivitame
// contensecript konteksti skripti contentscript.js. Käivitame kohe dokumendi saamisel kõigi freimide jaoks
"content_scripts": [
{
"matches": [
"http:/*/*",
"https:/*/*"
],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
// Lubatud juurdepääs localStorage ja idle API-le
"permissions": [
"storage",
// "unlimitedStorage",
//"clipboardWrite",
"idle"
//"activeTab",
//"webRequest",
//"notifications",
//"tabs"
],
// Siin määratakse ressursid, millele veebileht saab juurdepääsu. See tähendab, et neid saab taotleda fetch või lihtsalt xhr kaudu
"web_accessible_resources": ["inpage.js"]
}Loome tühjad failid background.js, popup.js, inpage.js ja contentscript.js. Lisame popup.html - ja meie rakendust saab juba laadida Google Chrome'i ning veenduda, et see töötab.
Selle kinnitamiseks võite võtta koodi . Lisaks sellele, mis me tegime, on lingil seadistatud projekti kokkupanek webpacki abil. Rakenduse brauserisse lisamiseks tuleb chrome://extensions valida 'load unpacked' ja kaust vastava laienduse jaoks - meie puhul dist.

Nüüd on meie laiendus installitud ja töötab. Arendajatööriistu erinevates kontekstides saab avada järgmiselt:
popup ->

Sisufaili konsooli pääseb kusagil lehe konsooli kaudu, millel see on käivitatud.
Sõnumite vahetamine
Seega peame seadma kaks suhtlusteed: inpage <-> background ja popup <-> background. Loomulikult saab lihtsalt saata sõnumeid pordile ja leiutada oma protokolli, kuid mulle meeldib lähenemine, mida nägin avatud lähtekoodiga projektis metamask.
See on brauserilaiendus, mis töötab Ethereum'i võrgu jaoks. Selles suhtlevad erinevad rakenduse osad RPC kaudu dnode'i teegiga. See võimaldab suhteid korraldada üsna kiiresti ja mugavalt, kui anda transportimiseks nodejs voog (siin mõeldakse objekti, mis rakendab sama liidest):
import Dnode from "dnode/browser";
// Selles näites lepime kokku, et klient kutsub funktsioone serverilt kaugelt, kuigi miski ei takista meid seda kahepoolseks muuta
// Server
// API, mida tahame pakkuda
const dnode = Dnode({
hello: (cb) => cb(null, "world")
})
// Transport, mille peal dnode töötab. Iga 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
// Väljund konsoolil 'world'
dnodeClient.once('remote', remote => {
remote.hello(((err, value) => console.log(value)))
})Nüüd loome rakenduse klassi. See loob API objekti popup'i ja veebilehe jaoks, samuti loob dnode'i nende jaoks:
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 pop-up 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 globaalse objekti Chrome asemel extentionApi, mis ühendub Chromega Google'i brauseris ja browseriga teistes. Seda tehakse selleks, et tagada brauserite ühilduvus, kuid antud artikli kontekstis oleks võimalik kasutada ka lihtsalt 'chrome.runtime.connect'.
Loome rakenduse instantsi taustaskriptis:
import {extensionApi} from './utils/extensionApi';
import {PortStream} from './utils/PortStream';
import {SignerApp} from './SignerApp';
const app = new SignerApp();
// onConnect aktiveerub 'protsesside' (contentscript, pop-up või laienduse leht) ühendamisel
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
// Ühenduse loomisel saab määrata nime, mille abil määrame, kes on ühendatud — kas contentscript või ui
if (processName === 'contentscript'){
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
}else{
app.connectPopup(portStream)
}
}Kuna dnode töötab streamidega ja me saame pordi, on vajalik adapter-klassi loomine. See on loodud 'readable-stream' teegi kaudu, mis realiseerib nodejs streamid 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 meie rakenduse klassis, loome pordi, mähkime selle stream'i, 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 konsoolist kättesaadavaks
if (DEV_MODE){
global.background = background;
}
}Seejärel loome ühenduse sisulehe skriptis:
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 {
// sisestame lehe sisse skripti
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('Sisestamine ebaõnnestus.', e);
}
}Kuna API-d vajame mitte sisulehe skriptis, vaid otse lehe peal, teeme kaks asja:
- Loome kaks stream'i. Üks — lehe poole, postMessage'i peal. Selle jaoks kasutame metamaski arendajatelt. Teine stream — taustaprogrammi poole, kasutades pordi, mille saime
runtime.connect. Kätkime neid. Nüüd on lehel stream taustaprogrammi poole. - Sisestame skripti DOM-i. 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 taustaprogrammi poole
const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
const backgroundStream = new PortStream(backgroundPort);
// Stream lehe poole
const pageStream = new PostMessageStream({
name: 'content',
target: 'page',
});
pageStream.pipe(backgroundStream).pipe(pageStream);
}
function injectScript(){
try {
// sisestame lehe sisse skripti
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('Sisestamine ebaõnnestus.', e);
}
}Nüüd loome objekt api inpage'is 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 poolel saame funktsiooni hello kutsuda järgmiselt:

Callback-funktsioonidega töötamine kaasaegses JS-is on moes, seega kirjutame väikese abi tööriista dnode loomiseks, mis võimaldab edasi anda objekti API-sse utils.
API objektid näevad nüüd välja sellised:
export class SignerApp {
popupApi() {
return {
hello: async () => "world"
}
}
...
}Remote objekti saamine järgmisel viisil:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// Utiliitide abil muudame kõik callback-d promiseks
resolve(transformMethods(cbToPromise, remoteApi))
})
});Ja funktsioonide väljakutsumine tagastab lubaduse:

Versioon asünkroonsätete funktsioonidega on saadaval .
Kokkuvõttes tundub RPC ja voogudega lähenemine piisavalt paindlik: saame kasutada vooge mitmekordistamist ja luua erinevate ülesannete jaoks mitu erinevat API-t. Prinspis, dnode'i saab kasutada igal pool, peamine on kapseldada transport nodejs voona.
Alternatiiviks on JSON-formaat, mis rakendab JSON RPC 2 protokolli. Kuid see töötab konkreetsete transpordiga (TCP ja HTTP(S)), mis meie juhul ei ole kohaldatav.
Sisemine seisund ja localStorage
Meil on vaja salvestada rakenduse sisene seisund — vähemalt allkirjastamiseks vajalikud võtmed. Saame suhteliselt lihtsalt lisada seisundi rakendusele ja meetodid selle muutmiseks pop-up API-sse:
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)
}
}
...
} Taustal kapseldame kõik funktsiooni ning kirjutame rakenduse objekti aknasse, 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 UI-st mõned võtmed konsoolist ja vaatame, kuidas olek välja näeb:

Olek tuleb teha püsivaks, et võtmed ei kaoks teiste käivituste korral.
Hoidmine toimub localStorage'is, kus iga muutumise korral salvestame üle. Tulevikus on vajalik ka sellele juurdepääs UI-st ning soovime samuti muutustele kuulata. Seetõttu on mõistlik luua vaadeldav salvestus (observable storage) ja kuulata selle muudatusi.
Kasutame raamatukogu mobx (). Selle valisime, kuna ei olnud eelnevalt töötanud selle raamatukoguga ja soovisime seda uurida.
Lisame algoleku initsialiseerimise ja teeme store'i vaadeldavaks:
import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
export class SignerApp {
constructor(initState = {}) {
\/\/ Välimuselt jääb store samaks objektiks, aga nüüd on kõik selle väljad proxy'd, mis jälgivad nendele juurdepääsu
this.store = observable.object({
keys: initState.keys || [],
});
}
\/\/ Meetodid, mis muudavad observable't, on tavaliselt kaunistatud dekoraatoriga
@action
addKey(key) {
this.store.keys.push(key)
}
@action
removeKey(index) {
this.store.keys.splice(index, 1)
}
...
}Mobx asendas „kapoti all“ kõik store'i väljad proxy'dega ja püüdleb kõik nendele juurdepääsud. Nendele juurdepääsudele saab ka kuulata.
Edasi tulen sageli kasutama mõistet "muudatuse korral", kuigi see ei ole täiesti korrektne. Mobx jälgib tegelikult väljadele juurdepääsu. Kasutatakse getter'eid ja setter'eid proxy-objektide jaoks, mida raamatukogu loob.
Dekoraatorid action täidavad kahte eesmärki:
- Range režiimis, kus on lubatud flag enforceActions, keelab mobx otseselt oleku muutmise. Heaks tooniks loetakse töötada just ranges režiimis.
- Isegi kui funktsioon muundab olekut mitu korda – näiteks, kui me muudame mitu välja mitmes koodireas, – teavitavad vaatajad ainult pärast selle lõpetamist. See on eriti oluline front-end'i puhul, kus liigsed olekuvärskendused viivad tarbetute elementide renderdamiseni. Meie puhul ei ole poolt ega teist eriti relevantne, kuid järgime parimaid praktikaid. Dekoraatoreid on tavaline lisada kõigile funktsioonidele, mis muundavad vaadeldavate väljade olekut.
Taustal lisame oleku initsialiseerimise ja salvestamise localStorage'i:
import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Abifunktsioonid. Salvestavad/loevad objekti localStorage'ist JSON stringina võtme 'store' alusel
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;
}
// Oleku püsimise seadistamine
// Reaction'i tulemus omistatakse muutujale, et tellimust saaks lõpetada. Meil ei ole seda vaja, see on jäetud näitena
const localStorageReaction = reaction(
() => toJS(app.store), // Andmete valijafunktsioon
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, mida kutsutakse välja nende andmete jaoks iga kord, kui need muutuvad.
Erinevalt redux'ist, kus me selgelt saame oleku argumendina, mäletab mobx, millistele vaadeldavatele me juurdepääsu teeme valijas, ning ainult nende muutumise korral kutsub käitlejat välja.
Oluline on mõista, kuidas mobx otsustab, millistele vaadeldavatele me tellime. Kui ma kirjutaksin koodi nii() => app.store, siis reaction'i ei kutsuta kunagi välja, kuna ise ei ole hoidla vaadeldav, vaadeldavad on ainult selle väljad.
Kui ma kirjutaksin nii () => app.store.keys, siis ei juhtuks jälle midagi, kuna massiivi elementide lisamisel/eemaldamisel viidatud massiivi link ei muutu.
Mobx esmakordselt täidab valija funktsiooni ja jälgib ainult neid observable'id, millele oleme juurde pääsenud. See toimub läbi proksi getterite. Seetõttu on siin kasutatud sisseehitatud funktsiooni toJS. See tagastab uue objekti, kus kõik proksi on asendatud originaalväljadega. Täideviimise käigus loeb ta kõiki objekti välju – seega käivituvad getterid.
Konsoli popupis lisame jälle mitu võtme. Seekord jõudsid need ka localStorage'isse:

Lehe taaskäivitamisel jääb teave paika.
Kogu rakenduse koodi kuni selle hetkeni saab vaadata .
Privaatsete võtmete turvaline hoidmine
Privaatsete võtmete hoidmine avatud kujul ei ole ohutu: alati on võimalus, et keegi murab teie seadmesse ja saab sellele ligi jne. Seetõttu kavatseme localStorage'is hoida võtmeid parooliga krüpteeritud kujul.
Rohkema turvalisuse tagamiseks lisame rakendusele lukustatud oleku, milles võtmetele ei toimu ligipääsu. Automaatne üleminek lukustatud olekusse toimub aja ülemineku tõttu.
Mobx võimaldab hoida ainult minimaalselt vajalikku andmekogumit ja ülejäänut arvutada automaatselt nende põhjal. Need on nn computed properties, mida saab võrrelda vaadetega andmebaasides:
import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Abstraktsed jaoks stringide ohutuks krüptimiseks. Kasutatakse crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";
export class SignerApp {
constructor(initState = {}) {
this.store = observable.object({
// Salvestame parooli ja krüptitud võti. Kui parool on null - rakendus on lukus
password: null,
vault: initState.vault,
// Getterid arvutatavatele väljadele. Saab teha analoogia vaatesse andmebaasis.
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 lukus')
}
}
// Meetodid hoiustamise krüptimiseks/dekrüptimiseks
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 hoiame ainult krüptitud võtmed ja parooli. Kõik muu arvutatakse. Me teeme ülemineku lukustatud olekusse, eemaldades parooli olekust. Avalikus API-s on ilmunud meetod hoiustamise initsialiseerimiseks.
Krüptimiseks on kirjutatud :
import CryptoJS from 'crypto-js'
// Kasutatakse parooli nuputamise 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)
}Brahva on idle API, mille kaudu saab tellida sündmust - oleku muutust. Oleku muutus võib olla vastavalt üksik, active ja lukustatud. Idle jaoks saab seadistada timeout'i, samas kui lukustatud seisund kehtestatakse, kui operatsioonisüsteem on blokeeritud. Samuti muudame selektorit, et salvestada localStorage-sse:
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 kutsub välja selgelt väljakutset, millele pääseme, reaction töötab normaalselt
reaction(
() => ({
vault: app.store.vault
}),
saveState
);
// Betsaegade timeout, kui sündmus aktiveerub
extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
// Kui kasutaja on ekraani lukustanud või ei ole teatud aja jooksul tegev, lukustame rakenduse
extensionApi.idle.onStateChanged.addListener(state => {
if (['locked', 'idle'].indexOf(state) > -1) {
app.lock()
}
});
// Ühendamine 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 oleme jõudnud kõige olulisemani: tehingute loomine ja allkirjastamine plokiahelas. Kasutame WAVES plokiahelat ja raamatukogu .
Alustame, lisades olekusse massiivi sõnumitega, mida tuleb allkirjastada, seejärel - meetoditega uue sõnumi lisamiseks, allkirja kinnitamiseks ja tagasilükkamiseks:
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) {
// Loome iga sõnumi jaoks metaandmed, sealhulgas id, olek, loomise aeg jne.
const message = observable.object({
id: uuid(), // Identifikaator, kasutades uuid
origin, // Origin, mida näitame hiljem liideses
data, //
status: 'new', // Olekeid on neli: new, signed, rejected ja failed
timestamp: Date.now()
});
console.log(`uus sõnum: ${JSON.stringify(message, null, 2)}`);
this.store.messages.push(message);
// Tagastame promis, mille sees mobx jälgib sõnumi muutusi. Niipea kui olek muutub, lahendame selle
return new Promise((resolve, reject) => {
reaction(
() => message.status, // Jälgime sõnumi olekut
(status, reaction) => { // Teine argument on viide reaktsioonile, et seda saaks vajadusel tühistada
switch (status) {
case 'signed':
resolve(message.data);
break;
case 'rejected':
reject(new Error('Kasutaja lükkas sõnumi tagasi'));
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 saamisel lisame sellele metaandmed, teeme observable ja lisame store.messages.
Kui seda ei tehta observable käsi, siis mobx teeb seda ise, kui see lisatakse messages massiivi. Kuid ta loob uue objekti, millele meil ei ole viidatud, ja see on vajalik järgmise sammu jaoks.
Seejärel tagastame promis, mis lahendatakse sõnumi oleku muutumisel. Oleku jälgib reaction, mis tapab end ise oleku muutumisel.
Meetodite kood approve ja reject on väga lihtne: muudame lihtsalt sõnumi olekut, allkirjastades selle eelnevalt, kui see on vajalik.
API UI-s me kinnitame ja keeldume, newMessage on API lehtedel:
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 allkirjastada tehingut laiendiga:

Üldiselt on kõik valmis, jääb veel .
UI
Kasutajaliides vajab juurdepääsu rakenduse olekule. UI poolel teeme observable oleku ja lisame API-sse funktsiooni, mis seda olekut muudab. Lisame observable backgroundilt saadud objekti API-s:
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 ja loome sellest streami
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 taustale 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 taustale observable olekuga
background.state = backgroundState;
if (DEV_MODE) {
global.background = background;
}
// Käivitame kasutajaliidese
await initApp(background)
}
Lõpuks käivitame rakenduse liidese renderdamise. See on react-rakendus. Taust-objekt edastatakse lihtsalt propside kaudu. Loomulikult oleks õige teha eraldi teenus meetodite jaoks ja store oleku jaoks, kuid selle artikli raames on sellest piisavalt:
import {render} from 'react-dom'
import App from '.\/App'
import React from "react";
// Käivitame rakenduse taust-objektiga propide kujul
export async function initApp(background){
render(
,
document.getElementById('app-content')
);
}
Mobxiga on andmete muutumisel renderdamine väga lihtne. Lihtsalt lisame pakendi observer'i dekoratori komponendile ja renderdus kutsub automaatselt esile muutusi, millega komponent viitab. Ei ole vaja mingeid mapStateToProps või connect, nagu reduxis. 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 \/\/ Komponendi jaoks, millel on see dekoraator, kutsub render meetodi automaatselt välja, kui muutuvad seostatavad väärtused, millele ta viitab
export default class App extends Component {
\/\/ Loomulikult on õigesti, et viia renderdamise loogika marsruutimise juurde ja mitte kasutada sissekootud ternaarseid operaatoreid,
\/\/ ja siduda seostatavad väärtused ja meetodid taustaga otse nendesse komponentidesse, 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()}>Lock App</button>}
{initialized && <button onclick="{()" > deleteVault()}>Delete all keys and init</button>}
</div>
</Fragment>
}
}Ülejäänud komponente saab vaadata koodis .
Nüüd on rakenduse klassis vajalik teha UI jaoks oleku valija ja selle muutumisel UI-d teavitada. Selle jaoks lisame meetodi getState ja reaction, kutsudes 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 reaktiivse tegevuse oleku muutustele, mis kutsub esile kaugele kätke ja värskendab olekut ui protsessis
const updateStateReaction = reaction(
() => this.getState(),
(state) => remote.updateState(state),
// Kolmandana saab edastada parameetreid. fireImmediatly tähendab, et reaktiivne tegevus toimib esmakordselt kohe.
// See on vajalik, et saada algne olek. Delay võimaldab paigaldada debounce
{fireImmediately: true, delay: 500}
);
// Eemaldame tellimuse, kui klient lahti ühendatakse
dnode.once('end', () => updateStateReaction.dispose())
})
}
...
}Olekute objekti saamisel remote loodakse reaction oleku muutumise peale, mis kutsub функция UI poole.
Viimane lihv — lisame uute sõnumite kuvamise laienduse ikoonile:
function setupApp() {
...
// Reaktsioon badge tooni seadmiseks.
reaction(
() => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
text => extensionApi.browserAction.setBadgeText({text}),
{fireImmediately: true}
);
...
}Nii et rakendus on valmis. Veebilehed võivad küsida tehingute allkirjastamist:


Kood on saadaval sellel .
Kokkuvõte
Kui olete artikli lõpuni lugenud, kuid teil on endiselt küsimusi, saate neid esitada . Seal leiate ka igat tähistatud sammu kohta commit'id.
Ja kui teid huvitab, kuidas näeb välja tõeline laiendus, leiate selle .
Kood, repositoorium ja töö kirjeldus autorilt
Allikas: habr.com
