It skriuwen fan in feilige browser-útwreiding

It skriuwen fan in feilige browser-útwreiding

Oars as de mienskiplike "client-server" arsjitektuer, wurde desintralisearre applikaasjes karakterisearre troch:

  • D'r is gjin needsaak om in databank op te slaan mei brûkerslogins en wachtwurden. Tagongsynformaasje wurdt eksklusyf opslein troch de brûkers sels, en befêstiging fan har autentisiteit bart op it protokolnivo.
  • Gjin needsaak om in tsjinner te brûken. De applikaasje logika kin útfierd wurde op in blokje netwurk, wêr't it mooglik is om de fereaske hoemannichte gegevens op te slaan.

D'r binne 2 relatyf feilige opslach foar brûkerskaaien - hardware-slúven en browser-útwreidingen. Hardware-slúven binne meast ekstreem feilich, mar lestich te brûken en fier fan fergees, mar browser-útwreidingen binne de perfekte kombinaasje fan feiligens en gemak fan gebrûk, en kinne ek folslein fergees wêze foar ein brûkers.

Mei dit alles yn rekken brocht, woenen wy de feilichste útwreiding meitsje dy't de ûntwikkeling fan desintralisearre applikaasjes ferienfâldiget troch in ienfâldige API te leverjen foar wurkjen mei transaksjes en hantekeningen.
Wy sille jo hjirûnder fertelle oer dizze ûnderfining.

It artikel sil stap-foar-stap ynstruksjes befetsje oer hoe't jo in browser-útwreiding skriuwe, mei koadefoarbylden en skermôfbyldings. Jo kinne fine alle koade yn repositories. Elke commit komt logysk oerien mei in seksje fan dit artikel.

In koarte skiednis fan blêderútwreidings

Browser-útwreidings bestean al in lange tiid. Se ferskynden yn Internet Explorer werom yn 1999, yn Firefox yn 2004. Lykwols, foar in hiel lange tiid wie der gjin inkele standert foar útwreidings.

Wy kinne sizze dat it ferskynde tegearre mei útwreidingen yn 'e fjirde ferzje fan Google Chrome. Fansels wie d'r doe gjin spesifikaasje, mar it wie de Chrome API dy't har basis waard: nei't se it measte fan 'e browsermerk ferovere en in ynboude applikaasjewinkel hie, sette Chrome eins de standert foar browser-útwreidings.

Mozilla hie in eigen standert, mar sjoen de populariteit fan Chrome-útwreidingen, besleat it bedriuw in kompatibele API te meitsjen. Yn 2015, op inisjatyf fan Mozilla, waard in spesjale groep makke binnen it World Wide Web Consortium (W3C) om te wurkjen oan spesifikaasjes foar cross-browser-útwreiding.

De besteande API-tafoegings foar Chrome waarden as basis nommen. It wurk waard útfierd mei de stipe fan Microsoft (Google wegere mei te dwaan oan 'e ûntwikkeling fan' e standert), en as gefolch ferskynde in ûntwerp spesifikaasjes.

Formeel wurdt de spesifikaasje stipe troch Edge, Firefox en Opera (notysje dat Chrome net op dizze list stiet). Mar feitlik is de standert foar in grut part kompatibel mei Chrome, om't it eins skreaun is op basis fan syn tafoegings. Jo kinne mear lêze oer de WebExtensions API hjir.

Extension struktuer

It ienige bestân dat nedich is foar de tafoeging is it manifest (manifest.json). It is ek it "yngongspunt" foar de útwreiding.

Manifest

Neffens de spesifikaasje is it manifestbestân in jildich JSON-bestân. In folsleine beskriuwing fan manifest kaaien mei ynformaasje oer hokker kaaien wurde stipe yn hokker browser kin wurde besjoen hjir.

Kaaien dy't net yn 'e spesifikaasje "meie" wurde negearre (sawol Chrome as Firefox melde flaters, mar de tafoegings wurkje fierder).

En ik soe graach omtinken jaan oan guon punten.

  1. eftergrûn - in objekt dat de folgjende fjilden omfettet:
    1. skripts - in array fan skripts dy't sille wurde útfierd yn 'e eftergrûnkontekst (wy sille hjir in bytsje letter oer prate);
    2. side - ynstee fan skripts dy't sille wurde útfierd op in lege side, kinne jo opjaan html mei ynhâld. Yn dit gefal sil it skriptfjild negearre wurde, en de skripts moatte ynfoege wurde yn 'e ynhâldside;
    3. Wiere Jezustsjerke - in binêre flagge, as net spesifisearre, sil de browser it eftergrûnproses "deadzje" as it fan betinken is dat it neat docht, en as it nedich is opnij starte. Oars wurdt de side pas útladen as de browser is sluten. Net stipe yn Firefox.
  2. ynhâld_skripts - in array fan objekten wêrmei jo ferskate skripts op ferskate websiden kinne laden. Elk objekt befettet de folgjende wichtige fjilden:
    1. wedstriden - patroan url, dy't bepaalt oft in bepaald ynhâldsskript opnommen wurdt of net.
    2. js - in list mei skripts dy't sille wurde laden yn dizze wedstriid;
    3. útslute_wedstriden - slút út it fjild match URL's dy't oerienkomme mei dit fjild.
  3. side_aksje - is eins in objekt dat ferantwurdlik is foar it ikoan dat wurdt werjûn neist de adresbalke yn 'e browser en ynteraksje mei it. It lit jo ek in popup-finster werjaan, dat wurdt definieare mei jo eigen HTML, CSS en JS.
    1. default_popup - paad nei it HTML-bestân mei de popup-ynterface, kin CSS en JS befetsje.
  4. tagongsrjochten - in array foar it behearen fan útwreidingsrjochten. D'r binne 3 soarten rjochten, dy't yn detail beskreaun wurde hjir
  5. web_accessible_resources - útwreidingsboarnen dy't in webside kin oanfreegje, bygelyks ôfbyldings, JS, CSS, HTML-bestannen.
  6. ekstern_ferbinber - hjir kinne jo de ID's fan oare tafoegings en domeinen fan websiden eksplisyt opjaan wêrfan jo kinne ferbine. In domein kin twadde nivo of heger wêze. Wurket net yn Firefox.

Eksekúsje kontekst

De tafoeging hat trije koade-útfierkonteksten, dat is, de applikaasje bestiet út trije dielen mei ferskate nivo's fan tagong ta de browser API.

Utwreiding kontekst

It grutste part fan 'e API is hjir te krijen. Yn dit ferbân "libje" se:

  1. Eftergrûn side - "backend" diel fan 'e útwreiding. De triem wurdt oantsjutte yn it manifest mei de "eftergrûn" kaai.
  2. Popup side - in popup-side dy't ferskynt as jo op it tafoegingsbyld klikke. Yn it manifest browser_action -> default_popup.
  3. Oanpaste side - útwreidingsside, "libje" yn in aparte ljepper fan 'e werjefte chrome-extension://<id_расширения>/customPage.html.

Dizze kontekst bestiet ûnôfhinklik fan browserfinsters en ljeppers. Eftergrûn side bestiet yn ien eksimplaar en wurket altyd (de útsûndering is de side foar eveneminten, as it eftergrûnskript wurdt lansearre troch in evenemint en "stjert" nei it útfieren dêrfan). Popup side bestiet as it popup finster is iepen, en Oanpaste side - wylst de ljepper dêrmei iepen is. D'r is gjin tagong ta oare ljeppers en har ynhâld út dizze kontekst.

Ynhâld skript kontekst

It ynhâldskriptbestân wurdt lansearre tegearre mei elke browser-ljepper. It hat tagong ta in diel fan 'e API fan' e tafoeging en ta de DOM-beam fan 'e webside. It binne ynhâldsskripts dy't ferantwurdlik binne foar ynteraksje mei de side. Tafoegings dy't de DOM-beam manipulearje, dogge dit yn ynhâldskripts - bygelyks advertinsjeblokkers of oersetters. Ek kin it ynhâldskript fia standert kommunisearje mei de side postMessage.

Webside kontekst

Dit is de eigentlike webside sels. It hat neat te krijen mei de útwreiding en hat dêr gjin tagong, útsein yn gefallen dêr't it domein fan dizze side net eksplisyt oanjûn is yn it manifest (mear hjiroer hjirûnder).

Berjochtútwikseling

Ferskillende dielen fan 'e applikaasje moatte berjochten mei elkoar útwikselje. D'r is in API foar dit runtime.sendMessage om in berjocht te stjoeren background и tabs.sendMessage om in berjocht nei in side te stjoeren (ynhâldskript, popup of webside as beskikber externally_connectable). Hjirûnder is in foarbyld by tagong ta de Chrome API.

// Сообщением может быть любой 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))
    }
)

Foar folsleine kommunikaasje kinne jo ferbiningen meitsje fia runtime.connect. As antwurd krije wy runtime.Port, dêr't jo, wylst it iepen is, elk oantal berjochten stjoere kinne. Oan 'e kant fan 'e klant, bygelyks, contentscript, it sjocht der sa út:

// Опять же 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 of eftergrûn:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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) {
    ...
});

Der is ek in evenemint onDisconnect en metoade disconnect.

Applikaasje diagram

Litte wy in browser-útwreiding meitsje dy't privee kaaien opslacht, tagong jout ta publike ynformaasje (adres, iepenbiere kaai kommunisearret mei de side en lit applikaasjes fan tredden in hantekening oanfreegje foar transaksjes.

Applikaasjeûntwikkeling

Us applikaasje moat sawol ynteraksje mei de brûker en de side leverje mei in API om metoaden op te roppen (bygelyks om transaksjes te ûndertekenjen). Meitsje it mei mar ien contentscript sil net wurkje, om't it allinich tagong hat ta de DOM, mar net ta de JS fan 'e side. Ferbine fia runtime.connect wy kinne net, omdat de API is nedich op alle domeinen, en allinnich spesifike kin oantsjutte yn it manifest. As resultaat sil it diagram der sa útsjen:

It skriuwen fan in feilige browser-útwreiding

D'r sil in oar skript wêze - inpage, dy't wy yn 'e side sille ynjeksje. It sil yn har kontekst rinne en in API leverje foar wurkjen mei de útwreiding.

Thús

Alle browser tafoeging koade is beskikber by GitHub. Tidens de beskriuwing sille d'r keppelings wêze nei commits.

Litte wy begjinne mei it manifest:

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

Meitsje lege background.js, popup.js, inpage.js en contentscript.js. Wy foegje popup.html ta - en ús applikaasje kin al yn Google Chrome laden wurde en soargje derfoar dat it wurket.

Om dit te ferifiearjen, kinne jo de koade nimme fan hjir. Neist wat wy diene, konfigureare de keppeling de gearstalling fan it projekt mei webpack. Om in applikaasje ta te foegjen oan 'e browser, moatte jo yn chrome://extensions selektearje load unpacked en de map mei de byhearrende tafoeging - yn ús gefal dist.

It skriuwen fan in feilige browser-útwreiding

No is ús tafoeging ynstalleare en wurket. Jo kinne de ûntwikkeldersark foar ferskate konteksten as folgjend útfiere:

popup ->

It skriuwen fan in feilige browser-útwreiding

Tagong ta de ynhâldskriptkonsole wurdt útfierd fia de konsole fan 'e side sels wêrop it wurdt lansearre.It skriuwen fan in feilige browser-útwreiding

Berjochtútwikseling

Dat, wy moatte twa kommunikaasjekanalen fêstigje: inpage <-> eftergrûn en popup <-> eftergrûn. Jo kinne fansels gewoan berjochten nei de haven stjoere en jo eigen protokol útfine, mar ik leaver de oanpak dy't ik seach yn it metamask iepen boarne-projekt.

Dit is in browser-útwreiding foar wurkjen mei it Ethereum-netwurk. Dêryn kommunisearje ferskate dielen fan 'e applikaasje fia RPC mei de dnode-bibleteek. It makket it mooglik om in útwikseling frij fluch en maklik te organisearjen as jo it leverje mei in nodejs-stream as ferfier (dat betsjut in objekt dat deselde ynterface ymplementearret):

import Dnode from "dnode/browser";

// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным

// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне

// Выведет в консоль world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

No sille wy in applikaasjeklasse oanmeitsje. It sil API-objekten meitsje foar de popup en webside, en in dnode foar har meitsje:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(remote)
        })
    }

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(origin);
            console.log(remote)
        })
    }
}

Hjir en hjirûnder, ynstee fan it globale Chrome-objekt, brûke wy extensionApi, dy't tagong hat ta Chrome yn Google's browser en browser yn oaren. Dit wurdt dien foar cross-browser kompatibiliteit, mar foar de doelen fan dit artikel koe men gewoan 'chrome.runtime.connect' brûke.

Litte wy in applikaasje-eksimplaar oanmeitsje yn it eftergrûnskript:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Sûnt dnode wurket mei streamen, en wy ûntfange in haven, is in adapter klasse nedich. It wurdt makke mei de bibleteek fan lêsbere stream, dy't nodejs-streamen yn 'e browser ymplementearret:

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()
    }
}

Litte wy no in ferbining meitsje yn 'e 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(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    const background = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

Dan meitsje wy de ferbining yn it ynhâldskript:

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);
    }
}

Om't wy de API net nedich binne yn it ynhâldskript, mar direkt op 'e side, dogge wy twa dingen:

  1. Wy meitsje twa streamen. Ien - rjochting de side, boppe op it postBerjocht. Hjirfoar brûke wy dit dit pakket fan 'e makkers fan metamask. De twadde stream is nei eftergrûn oer de haven ûntfongen fan runtime.connect. Litte wy se keapje. No sil de side in stream nei de eftergrûn hawwe.
  2. Ynjeksje it skript yn 'e DOM. Download it skript (tagong ta it wie tastien yn it manifest) en meitsje in tag script mei syn ynhâld binnen:

import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";

setupConnection();
injectScript();

function setupConnection(){
    // Стрим к бекграунду
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Стрим к странице
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

No meitsje wy in api-objekt yn inpage en set it op globaal:

import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Стрим к контентскрипту
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    // Получаем объект API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Доступ через window
    global.SignerApp = pageApi;
}

Wy binne klear Remote Procedure Call (RPC) mei aparte API foar side en UI. By it ferbinen fan in nije side mei de eftergrûn kinne wy ​​dit sjen:

It skriuwen fan in feilige browser-útwreiding

Lege API en oarsprong. Oan 'e side kinne wy ​​de hello-funksje sa neame:

It skriuwen fan in feilige browser-útwreiding

Wurkje mei werombelfunksjes yn moderne JS is minne manieren, dus litte wy in lytse helper skriuwe om in dnode te meitsjen wêrmei jo in API-objekt oan utils kinne trochjaan.

De API-objekten sille no der sa útsjen:

export class SignerApp {

    popupApi() {
        return {
            hello: async () => "world"
        }
    }

...

}

In objekt fan ôfstân krije lykas dit:

import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // С помощью утилит меняем все callback на promise
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

En opropfunksjes jout in belofte werom:

It skriuwen fan in feilige browser-útwreiding

Ferzje mei asynchrone funksjes beskikber hjir.

Oer it algemien liket de RPC- en stream-oanpak frij fleksibel: wy kinne steammultiplexing brûke en ferskate ferskillende API's meitsje foar ferskate taken. Yn prinsipe kin dnode oeral brûkt wurde, it wichtichste is om it ferfier yn 'e foarm fan in nodejsstream te wikkeljen.

In alternatyf is it JSON-formaat, dat it protokol JSON RPC 2 ymplementearret. It wurket lykwols mei spesifike transporten (TCP en HTTP(S)), wat yn ús gefal net fan tapassing is.

Ynterne steat en lokaleStorage

Wy sille de ynterne tastân fan 'e applikaasje moatte opslaan - op syn minst de ûndertekeningkaaien. Wy kinne frij maklik in steat tafoegje oan 'e applikaasje en metoaden om it te feroarjen yn' e popup API:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

    removeKey(index){
        this.store.keys.splice(index,1)
    }

    popupApi(){
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index)
        }
    }

    ...

} 

Op de eftergrûn sille wy alles yn in funksje ynpakke en it applikaasjeobjekt nei it finster skriuwe, sadat wy dermei kinne wurkje fanút de konsole:

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)
        }
    }
}

Litte wy in pear kaaien tafoegje fan 'e UI-konsole en sjen wat der bart mei de steat:

It skriuwen fan in feilige browser-útwreiding

De steat moat persistint makke wurde, sadat de kaaien net ferlern gean by it opnij starte.

Wy sille it opslaan yn localStorage, it oerskriuwen mei elke feroaring. Dêrnei sil tagong ta it ek nedich wêze foar de UI, en ik soe ek graach wolle abonnearje op feroarings. Op grûn dêrfan sil it handich wêze om in waarneembare opslach te meitsjen en te abonnearjen op de wizigingen.

Wy sille de mobx-bibleteek brûke (https://github.com/mobxjs/mobx). De kar foel der op om't ik der net mei hoegde te wurkjen, mar ik woe it wol studearje.

Litte wy inisjalisaasje fan 'e inisjele steat tafoegje en de winkel waarneembaar meitsje:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(initState = {}) {
        // Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    // Методы, которые меняют observable принято оборачивать декоратором
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

    @action
    removeKey(index) {
        this.store.keys.splice(index, 1)
    }

    ...

}

"Under de motorkap," mobx hat ferfongen alle winkel fjilden mei proxy en ûnderskept alle oproppen nei harren. It sil mooglik wêze om te abonnearjen op dizze berjochten.

Hjirûnder sil ik faaks de term "by feroarjen" brûke, hoewol dit net hielendal korrekt is. Mobx tracks tagong ta fjilden. Getters en setters fan proxy-objekten dy't de bibleteek oanmakket wurde brûkt.

Action decorators tsjinje twa doelen:

  1. Yn strikte modus mei de flagge enforceActions ferbiedt mobx it direkt feroarjen fan de steat. It wurdt beskôge as goede praktyk om ûnder strikte betingsten te wurkjen.
  2. Sels as in funksje de steat ferskate kearen feroaret - wy feroarje bygelyks ferskate fjilden yn ferskate rigels koade - wurde de waarnimmers allinich op 'e hichte brocht as it foltôge is. Dit is foaral wichtich foar it frontend, wêr't ûnnedige steatupdates liede ta ûnnedige rendering fan eleminten. Yn ús gefal is noch de earste noch de twadde benammen relevant, mar wy sille de bêste praktiken folgje. It is gewoanlik om dekorators te heakjen oan alle funksjes dy't de steat fan 'e waarnommen fjilden feroarje.

Op 'e eftergrûn sille wy inisjalisaasje tafoegje en de steat opslaan yn localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

De reaksjefunksje is hjir nijsgjirrich. It hat twa arguminten:

  1. Data selector.
  2. In handler dy't sil wurde neamd mei dizze gegevens eltse kear as it feroaret.

Oars as redux, dêr't wy eksplisyt ûntfange de steat as argumint, ûnthâldt mobx hokker observables wy tagong binnen de selector, en allinne ropt de handler as se feroarje.

It is wichtich om krekt te begripen hoe't mobx beslút hokker observabelen wy abonnearje op. As ik skreau in selector yn koade like this() => app.store, dan sil reaksje nea neamd wurde, om't de opslach sels net te observearjen is, allinich syn fjilden binne.

As ik it sa skreau () => app.store.keys, dan soe der wer neat barre, om't by it tafoegjen/ferwiderjen fan array-eleminten de ferwizing dêrnei net feroaret.

Mobx fungearret foar it earst as selektor en hâldt allinich observabels by dy't wy tagong hawwe. Dit wurdt dien fia proxy-getters. Dêrom wurdt hjir de ynboude funksje brûkt toJS. It jout in nij objekt mei alle proxy's ferfongen troch de orizjinele fjilden. Tidens de útfiering lêst it alle fjilden fan it objekt - dêrtroch wurde de getters aktivearre.

Yn 'e popup-konsole sille wy wer ferskate kaaien tafoegje. Dizze kear bedarren se ek yn localStorage:

It skriuwen fan in feilige browser-útwreiding

As de eftergrûnside opnij wurdt laden, bliuwt de ynformaasje op syn plak.

Alle applikaasjekoade oant dit punt kinne wurde besjoen hjir.

Feilige opslach fan privee kaaien

It opslaan fan privee kaaien yn dúdlike tekst is ûnfeilich: der is altyd in kâns dat jo hackt wurde, tagong krije ta jo kompjûter, ensfh. Dêrom sille wy yn localStorage de kaaien opslaan yn in wachtwurd-fersifere foarm.

Foar gruttere feiligens sille wy in beskoattele tastân tafoegje oan 'e applikaasje, wêryn d'r hielendal gjin tagong sil wêze ta de kaaien. Wy sille de útwreiding automatysk oerdrage nei de beskoattele steat fanwege in time-out.

Mobx lit jo mar in minimale set fan gegevens opslaan, en de rest wurdt automatysk berekkene op basis dêrfan. Dit binne de saneamde computed eigenskippen. Se kinne wurde fergelike mei werjeften yn databases:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

    // private
    _checkPassword(password) {
        SignerApp._decryptVault(this.store.vault, password);
    }

    _checkLocked() {
        if (this.store.locked){
            throw new Error('App is locked')
        }
    }

    // Методы для шифровки/дешифровки хранилища
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

No bewarje wy allinich de fersifere kaaien en wachtwurd. Al it oare wurdt berekkene. Wy dogge de oerdracht nei in beskoattele steat troch it wachtwurd fan 'e steat te ferwiderjen. De iepenbiere API hat no in metoade foar it inisjalisearjen fan de opslach.

Skreaun foar fersifering nutsbedriuwen dy't crypto-js brûke:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

De browser hat in idle API wêrmei jo kinne abonnearje op in evenemint - steat feroarings. Steat, neffens, kin wêze idle, active и locked. Foar idle kinne jo in timeout ynstelle, en beskoattele wurdt ynsteld as it OS sels is blokkearre. Wy sille ek de selektor feroarje foar opslaan nei localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';
const IDLE_INTERVAL = 30;

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

De koade foar dizze stap is hjir.

Transaksjes

Dat, wy komme by it wichtichste ding: it meitsjen en ûndertekenjen fan transaksjes op 'e blockchain. Wy sille de WAVES blockchain en biblioteek brûke weagen-transaksjes.

Litte wy earst in array fan berjochten tafoegje oan 'e steat dy't moatte wurde ûndertekene, en foegje dan metoaden ta foar it tafoegjen fan in nij berjocht, befêstigje de hantekening en wegerjen:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, //Будем обсервить статус сообщеня
                (status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('User rejected message'));
                            break;
                        case 'failed':
                            reject(new Error(message.err.message));
                            break;
                        default:
                            return
                    }
                    reaction.dispose()
                }
            )
        })
    }
    @action
    approve(id, keyIndex = 0) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        try {
            message.data = signTx(message.data, this.store.keys[keyIndex]);
            message.status = 'signed'
        } catch (e) {
            message.err = {
                stack: e.stack,
                message: e.message
            };
            message.status = 'failed'
            throw e
        }
    }
    @action
    reject(id) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        message.status = 'rejected'
    }

    ...
}

As wy in nij berjocht ûntfange, foegje wy metadata ta, do observable en tafoegje oan store.messages.

As jo ​​dat net dogge observable mei de hân, dan sil mobx it sels dwaan by it tafoegjen fan berjochten oan 'e array. It sil lykwols in nij objekt meitsje wêr't wy gjin referinsje nei hawwe, mar wy sille it nedich hawwe foar de folgjende stap.

Dêrnei jouwe wy in belofte werom dy't oplost as de berjochtstatus feroaret. De status wurdt kontrolearre troch reaksje, dy't "sels deadzje" as de status feroaret.

Metoade koade approve и reject hiel ienfâldich: wy feroarje gewoan de status fan it berjocht, nei ûndertekening as it nedich is.

Wy sette goedkarre en ôfwize yn 'e UI API, newMessage yn' e side API:

export class SignerApp {
    ...
    popupApi() {
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index),

            lock: async () => this.lock(),
            unlock: async (password) => this.unlock(password),
            initVault: async (password) => this.initVault(password),

            approve: async (id, keyIndex) => this.approve(id, keyIndex),
            reject: async (id) => this.reject(id)
        }
    }

    pageApi(origin) {
        return {
            signTransaction: async (txParams) => this.newMessage(txParams, origin)
        }
    }

    ...
}

Litte wy no besykje de transaksje te ûndertekenjen mei de útwreiding:

It skriuwen fan in feilige browser-útwreiding

Yn 't algemien is alles klear, alles wat oerbliuwt is add ienfâldige UI.

UI

De ynterface hat tagong ta de tapassing tastân nedich. Oan 'e UI-kant sille wy dwaan observable steat en heakje in funksje ta oan de API dat sil feroarje dizze steat. Litte wy tafoegje observable nei it API-objekt ûntfongen fan eftergrûn:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

    if (DEV_MODE) {
        global.background = background;
    }

    // Запуск интерфейса
    await initApp(background)
}

Oan 'e ein begjinne wy ​​​​de applikaasje-ynterface te werjaan. Dit is in reaksjeapplikaasje. It eftergrûnobjekt wurdt gewoan trochjûn mei help fan rekwisieten. It soe fansels korrekt wêze om in aparte tsjinst te meitsjen foar metoaden en in winkel foar de steat, mar foar it doel fan dit artikel is dit genôch:

import {render} from 'react-dom'
import App from './App'
import React from "react";

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

Mei mobx is it heul maklik om te begjinnen mei rendering as gegevens feroarje. Wy hingje gewoan de observer-dekorator út it pakket mobx-reagearje op de komponint, en render sil automatysk neamd wurde as alle observables ferwiisd troch de komponint feroarje. Jo hawwe gjin mapStateToProps nedich of ferbine lykas yn redux. Alles wurket direkt út 'e doaze:

import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'

@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

    // Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
    // и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <Fragment>
            {!initialized
                ?
                <Init onInit={initVault}/>
                :
                locked
                    ?
                    <Unlock onUnlock={unlock}/>
                    :
                    messages.length > 0
                        ?
                        <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
                        :
                        <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
            }
            <div>
                {!locked && <button onClick={() => lock()}>Lock App</button>}
                {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

De oerbleaune komponinten kinne wurde besjoen yn 'e koade yn 'e UI-map.

No moatte jo yn 'e applikaasjeklasse in steatselektor meitsje foar de UI en de UI ynformearje as it feroaret. Om dit te dwaan, litte wy in metoade tafoegje getState и reactioncalling remote.updateState:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    // public
    getState() {
        return {
            keys: this.store.keys,
            messages: this.store.newMessages,
            initialized: this.store.initialized,
            locked: this.store.locked
        }
    }

    ...

    //
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = setupDnode(connectionStream, api);

        dnode.once('remote', (remote) => {
            // Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
                // Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
                {fireImmediately: true, delay: 500}
            );
            // Удалим подписку при отключении клиента
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

By it ûntfangen fan in objekt remote makke reaction om de steat te feroarjen dy't de funksje op 'e UI-kant neamt.

De lêste touch is om de werjefte fan nije berjochten ta te foegjen op it útwreidingsbyldkaike:

function setupApp() {
...

    // Reaction на выставление текста беджа.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Dat, de applikaasje is klear. Websiden kinne in hantekening oanfreegje foar transaksjes:

It skriuwen fan in feilige browser-útwreiding

It skriuwen fan in feilige browser-útwreiding

De koade is hjir beskikber link.

konklúzje

As jo ​​hawwe lêzen it artikel oan 'e ein, mar dochs hawwe fragen, kinne jo freegje se op repositories mei útwreiding. Dêr sille jo ek commits fine foar elke oanwiisde stap.

En as jo ynteressearre binne om te sjen nei de koade foar de eigentlike útwreiding, kinne jo dit fine hjir.

Koade, repository en taakbeskriuwing fan siemarell

Boarne: www.habr.com

Add a comment