Писане на защитено разширение за браузър

Писане на защитено разширение за браузър

За разлика от общата архитектура „клиент-сървър“, децентрализираните приложения се характеризират с:

  • Няма нужда да съхранявате база данни с потребителски имена и пароли. Информацията за достъп се съхранява изключително от самите потребители и потвърждаването на тяхната автентичност се извършва на ниво протокол.
  • Няма нужда да използвате сървър. Логиката на приложението може да се изпълнява в блокчейн мрежа, където е възможно да се съхранява необходимото количество данни.

Има 2 относително безопасни хранилища за потребителски ключове - хардуерни портфейли и разширения на браузъра. Хардуерните портфейли са предимно изключително сигурни, но трудни за използване и далеч не са безплатни, но разширенията на браузъра са перфектната комбинация от сигурност и лекота на използване и могат да бъдат напълно безплатни за крайните потребители.

Като вземем всичко това предвид, искахме да направим най-сигурното разширение, което опростява разработката на децентрализирани приложения, като предоставя прост API за работа с транзакции и подписи.
Ще ви разкажем за това преживяване по-долу.

Статията ще съдържа инструкции стъпка по стъпка как да напишете разширение за браузър, с примерни кодове и екранни снимки. Можете да намерите целия код в хранилища. Всеки ангажимент логически съответства на раздел от тази статия.

Кратка история на разширенията на браузъра

Разширенията за браузъри съществуват от дълго време. Те се появиха в Internet Explorer през 1999 г., във Firefox през 2004 г. Въпреки това, много дълго време нямаше единен стандарт за разширения.

Можем да кажем, че се появи заедно с разширенията в четвъртата версия на Google Chrome. Разбира се, тогава нямаше спецификация, но API на Chrome стана неговата основа: след като завладя по-голямата част от пазара на браузъри и разполагаше с вграден магазин за приложения, Chrome всъщност постави стандарта за разширения на браузъра.

Mozilla имаше свой собствен стандарт, но виждайки популярността на разширенията на Chrome, компанията реши да създаде съвместим API. През 2015 г. по инициатива на Mozilla беше създадена специална група в рамките на World Wide Web Consortium (W3C), която да работи върху спецификациите на разширенията за различни браузъри.

За основа бяха взети съществуващите API разширения за Chrome. Работата беше извършена с подкрепата на Microsoft (Google отказа да участва в разработването на стандарта) и в резултат на това се появи чернова специализация.

Формално спецификацията се поддържа от Edge, Firefox и Opera (имайте предвид, че Chrome не е в този списък). Но всъщност стандартът е до голяма степен съвместим с Chrome, тъй като всъщност е написан въз основа на неговите разширения. Можете да прочетете повече за API на WebExtensions тук.

Структура на разширение

Единственият файл, който е необходим за разширението, е манифестът (manifest.json). Това е и „входната точка“ към разширяването.

манифест

Според спецификацията манифестният файл е валиден JSON файл. Може да се види пълно описание на манифестни ключове с информация за това кои ключове се поддържат в кой браузър тук.

Ключовете, които не са в спецификацията, „може“ да бъдат игнорирани (и Chrome, и Firefox съобщават за грешки, но разширенията продължават да работят).

И бих искал да обърна внимание на някои точки.

  1. фон — обект, който включва следните полета:
    1. скриптове — масив от скриптове, които ще бъдат изпълнени във фонов контекст (ще говорим за това малко по-късно);
    2. страница - вместо скриптове, които ще се изпълняват в празна страница, можете да посочите html със съдържание. В този случай полето за скрипт ще бъде игнорирано и скриптовете ще трябва да бъдат вмъкнати в страницата със съдържание;
    3. упорит — двоичен флаг, ако не е зададен, браузърът ще „убие“ фоновия процес, когато прецени, че не прави нищо, и ще го рестартира, ако е необходимо. В противен случай страницата ще се разтовари само когато браузърът е затворен. Не се поддържа във Firefox.
  2. съдържание_скриптове — масив от обекти, който ви позволява да зареждате различни скриптове на различни уеб страници. Всеки обект съдържа следните важни полета:
    1. кибрит - URL адрес на образец, което определя дали определен скрипт за съдържание ще бъде включен или не.
    2. js — списък със скриптове, които ще бъдат заредени в този мач;
    3. exclude_matchs - изключва от полето match URL адреси, които съответстват на това поле.
  3. page_action - всъщност е обект, който отговаря за иконата, която се показва до адресната лента в браузъра и взаимодействието с нея. Той също така ви позволява да показвате изскачащ прозорец, който е дефиниран с помощта на вашия собствен HTML, CSS и JS.
    1. default_popup — пътят към HTML файла с изскачащ интерфейс, може да съдържа CSS и JS.
  4. разрешения — масив за управление на правата за разширение. Има 3 вида права, които са описани подробно тук
  5. уеб_достъпни_ресурси — ресурси за разширение, които една уеб страница може да изисква, например изображения, JS, CSS, HTML файлове.
  6. външно_свързваем — тук можете изрично да посочите идентификаторите на други разширения и домейни на уеб страници, от които можете да се свържете. Домейнът може да бъде от второ ниво или по-високо. Не работи във Firefox.

Контекст на изпълнение

Разширението има три контекста за изпълнение на код, тоест приложението се състои от три части с различни нива на достъп до API на браузъра.

Контекст на разширението

Повечето от API са достъпни тук. В този контекст те „живеят“:

  1. Фонова страница — „backend“ част от разширението. Файлът е посочен в манифеста с помощта на ключа „фон“.
  2. Изскачаща страница — изскачаща страница, която се появява, когато щракнете върху иконата на разширението. В манифеста browser_action -> default_popup.
  3. Персонализирана страница — страница с разширение, „живееща“ в отделен раздел на изгледа chrome-extension://<id_расширения>/customPage.html.

Този контекст съществува независимо от прозорците и разделите на браузъра. Фонова страница съществува в едно копие и винаги работи (изключение е страницата на събитието, когато фоновият скрипт се стартира от събитие и "умира" след неговото изпълнение). Изскачаща страница съществува, когато изскачащият прозорец е отворен и Персонализирана страница — докато разделът с него е отворен. Няма достъп до други раздели и тяхното съдържание от този контекст.

Контекст на скрипта на съдържанието

Файлът със скрипт със съдържание се стартира заедно с всеки раздел на браузъра. Има достъп до част от API на разширението и до DOM дървото на уеб страницата. Скриптовете за съдържание са отговорни за взаимодействието със страницата. Разширенията, които манипулират DOM дървото, правят това в скриптове за съдържание - например рекламни блокери или преводачи. Освен това скриптът за съдържание може да комуникира със страницата чрез стандарт postMessage.

Контекст на уеб страница

Това е самата действителна уеб страница. Няма нищо общо с разширението и няма достъп там, освен в случаите, когато домейнът на тази страница не е изрично посочен в манифеста (повече за това по-долу).

Обмен на съобщения

Различните части на приложението трябва да обменят съобщения помежду си. Има API за това runtime.sendMessage за да изпратите съобщение background и tabs.sendMessage за изпращане на съобщение до страница (скрипт със съдържание, изскачащ прозорец или уеб страница, ако има такава externally_connectable). По-долу е даден пример при достъп до API на Chrome.

// Сообщением может быть любой JSON сериализуемый объект
const msg = {a: 'foo', b: 'bar'};

// extensionId можно не указывать, если мы хотим послать сообщение 'своему' расширению (из ui или контент скрипта)
chrome.runtime.sendMessage(extensionId, msg);

// Так выглядит обработчик
chrome.runtime.onMessage.addListener((msg) => console.log(msg))

// Можно слать сообщения вкладкам зная их id
chrome.tabs.sendMessage(tabId, msg)

// Получить к вкладкам и их id можно, например, вот так
chrome.tabs.query(
    {currentWindow: true, active : true},
    function(tabArray){
      tabArray.forEach(tab => console.log(tab.id))
    }
)

За пълна комуникация можете да създавате връзки чрез runtime.connect. В отговор ще получим runtime.Port, на който, докато е отворен, можете да изпращате произволен брой съобщения. От страна на клиента, напр. contentscript, изглежда така:

// Опять же 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"});

Сървър или фон:

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

Има и събитие onDisconnect и метод disconnect.

Диаграма на приложение

Нека направим разширение за браузър, което съхранява лични ключове, предоставя достъп до публична информация (адрес, публичен ключ комуникира със страницата и позволява на приложения на трети страни да изискват подпис за транзакции.

Разработка на приложения

Нашето приложение трябва едновременно да взаимодейства с потребителя и да предоставя на страницата API за извикване на методи (например за подписване на транзакции). Задоволи се само с едно contentscript няма да работи, тъй като има достъп само до DOM, но не и до JS на страницата. Свързване чрез runtime.connect не можем, защото API е необходим на всички домейни и само конкретни могат да бъдат посочени в манифеста. В резултат на това диаграмата ще изглежда така:

Писане на защитено разширение за браузър

Ще има друг сценарий - inpage, които ще инжектираме в страницата. Той ще работи в своя контекст и ще предостави API за работа с разширението.

Начало

Целият код за разширение на браузъра е достъпен на GitHub. По време на описанието ще има връзки към ангажименти.

Да започнем с манифеста:

{
  // Имя и описание, версия. Все это будет видно в браузере в 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"]
}

Създайте празни background.js, popup.js, inpage.js и contentscript.js. Добавяме popup.html - и нашето приложение вече може да бъде заредено в Google Chrome и да се уверите, че работи.

За да проверите това, можете да вземете кода следователно. В допълнение към това, което направихме, връзката конфигурира сглобяването на проекта с помощта на webpack. За да добавите приложение към браузъра, в chrome://extensions трябва да изберете load unpacked и папката със съответното разширение - в нашия случай dist.

Писане на защитено разширение за браузър

Сега нашето разширение е инсталирано и работи. Можете да стартирате инструментите за разработчици за различни контексти, както следва:

изскачащ прозорец ->

Писане на защитено разширение за браузър

Достъпът до конзолата на скрипта за съдържание се осъществява през конзолата на самата страница, на която се стартира.Писане на защитено разширение за браузър

Обмен на съобщения

И така, трябва да установим два комуникационни канала: inpage <-> background и popup <-> background. Можете, разбира се, просто да изпращате съобщения до порта и да измислите свой собствен протокол, но аз предпочитам подхода, който видях в проекта с отворен код metamask.

Това е разширение за браузър за работа с мрежата Ethereum. В него различни части на приложението комуникират чрез RPC, използвайки библиотеката dnode. Тя ви позволява да организирате обмен доста бързо и удобно, ако му предоставите nodejs поток като транспорт (което означава обект, който изпълнява същия интерфейс):

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

Сега ще създадем клас на приложение. Той ще създаде API обекти за изскачащия прозорец и уеб страницата и ще създаде dnode за тях:

import Dnode from 'dnode/browser';

export class SignerApp {

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

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

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

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

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

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

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

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

Тук и по-долу, вместо глобалния обект на Chrome, използваме extensionApi, който осъществява достъп до Chrome в браузъра на Google и браузъра в други. Това се прави за съвместимост между различни браузъри, но за целите на тази статия може просто да се използва „chrome.runtime.connect“.

Нека създадем екземпляр на приложение във фоновия скрипт:

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

Тъй като dnode работи с потоци и получаваме порт, е необходим клас адаптер. Направен е с помощта на библиотеката readable-stream, която имплементира nodejs потоци в браузъра:

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

Сега нека създадем връзка в потребителския интерфейс:

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

След това създаваме връзката в скрипта за съдържание:

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

Тъй като имаме нужда от API не в скрипта за съдържание, а директно на страницата, правим две неща:

  1. Създаваме два потока. Един - към страницата, в горната част на съобщението. За това използваме това този пакет от създателите на метамаската. Вторият поток е на заден план през порта, получен от runtime.connect. Да ги купим. Сега страницата ще има поток на заден план.
  2. Инжектирайте скрипта в DOM. Изтеглете скрипта (достъпът до него беше разрешен в манифеста) и създайте таг script със съдържанието вътре:

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

Сега създаваме api обект в inpage и го настройваме на глобален:

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

Ние сме готови Извикване на отдалечена процедура (RPC) с отделен API за страница и потребителски интерфейс. Когато свързваме нова страница към фон, можем да видим това:

Писане на защитено разширение за браузър

Празен API и произход. От страната на страницата можем да извикаме функцията hello по следния начин:

Писане на защитено разширение за браузър

Работата с функции за обратно извикване в съвременния JS е лошо възпитание, така че нека напишем малък помощник за създаване на dnode, който ви позволява да предавате API обект на utils.

Обектите на API сега ще изглеждат така:

export class SignerApp {

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

...

}

Получаване на обект от дистанционно по този начин:

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

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

И извикването на функции връща обещание:

Писане на защитено разширение за браузър

Налична версия с асинхронни функции тук.

Като цяло подходът на RPC и потока изглежда доста гъвкав: можем да използваме парно мултиплексиране и да създадем няколко различни API за различни задачи. По принцип dnode може да се използва навсякъде, основното е да обвиете транспорта под формата на nodejs поток.

Алтернатива е форматът JSON, който имплементира протокола JSON RPC 2. Той обаче работи със специфични транспорти (TCP и HTTP(S)), което не е приложимо в нашия случай.

Вътрешно състояние и локално хранилище

Ще трябва да съхраним вътрешното състояние на приложението - поне ключовете за подписване. Можем доста лесно да добавим състояние към приложението и методи за промяната му в изскачащия 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)
        }
    }

    ...

} 

Във фонов режим ще обвием всичко във функция и ще запишем обекта на приложението в прозореца, така че да можем да работим с него от конзолата:

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

Нека добавим няколко ключа от UI конзолата и да видим какво ще се случи със състоянието:

Писане на защитено разширение за браузър

Състоянието трябва да се направи постоянно, така че ключовете да не се губят при рестартиране.

Ще го съхраняваме в localStorage, като го презаписваме при всяка промяна. Впоследствие достъпът до него ще бъде необходим и за потребителския интерфейс и бих искал също да се абонирам за промени. Въз основа на това ще бъде удобно да създадете наблюдаемо хранилище и да се абонирате за неговите промени.

Ще използваме библиотеката mobx (https://github.com/mobxjs/mobx). Изборът падна върху него, защото не трябваше да работя с него, но наистина исках да го изучавам.

Нека добавим инициализация на първоначалното състояние и да направим магазина видим:

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

    ...

}

„Под капака“ mobx замени всички полета на магазина с прокси и прихваща всички обаждания към тях. Ще бъде възможно да се абонирате за тези съобщения.

По-долу често ще използвам термина „при промяна“, въпреки че това не е съвсем правилно. Mobx проследява достъпа до полета. Използват се гетери и сетери на прокси обекти, които библиотеката създава.

Екшън декораторите служат за две цели:

  1. В строг режим с флага enforceActions, mobx забранява промяната на състоянието директно. Счита се за добра практика да се работи при строги условия.
  2. Дори ако дадена функция промени състоянието си няколко пъти - например променим няколко полета в няколко реда код - наблюдателите се уведомяват само когато тя завърши. Това е особено важно за интерфейса, където ненужните актуализации на състоянието водят до ненужно изобразяване на елементи. В нашия случай нито първото, нито второто е особено актуално, но ще следваме добрите практики. Обичайно е да се прикачват декоратори към всички функции, които променят състоянието на наблюдаваните полета.

Във фонов режим ще добавим инициализация и запазване на състоянието в 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)
        }
    }
}

Реакционната функция е интересна тук. Има два аргумента:

  1. Селектор на данни.
  2. Манипулатор, който ще се извиква с тези данни всеки път, когато се промени.

За разлика от redux, където изрично получаваме състоянието като аргумент, mobx запомня кои наблюдаеми имаме достъп вътре в селектора и извиква манипулатора само когато те се променят.

Важно е да разберем как точно mobx решава за кои наблюдавани се абонираме. Ако напиша селектор в код като този() => app.store, тогава реакцията никога няма да бъде извикана, тъй като самото хранилище не се наблюдава, а само неговите полета.

Ако го написах така () => app.store.keys, тогава отново няма да се случи нищо, тъй като при добавяне/премахване на елементи от масива препратката към него няма да се промени.

Mobx действа като селектор за първи път и следи само наблюдаемите, до които сме осъществили достъп. Това става чрез прокси гетери. Следователно тук се използва вградената функция toJS. Той връща нов обект с всички проксита, заменени с оригиналните полета. По време на изпълнението той чете всички полета на обекта - следователно гетерите се задействат.

В изскачащата конзола отново ще добавим няколко клавиша. Този път те също се озоваха в localStorage:

Писане на защитено разширение за браузър

Когато фоновата страница се презареди, информацията остава на мястото си.

Може да се види целият код на приложението до този момент тук.

Сигурно съхранение на частни ключове

Съхраняването на частни ключове в чист текст не е безопасно: винаги има шанс да бъдете хакнати, да получите достъп до компютъра си и т.н. Следователно в localStorage ще съхраняваме ключовете в криптирана с парола форма.

За по-голяма сигурност ще добавим заключено състояние на приложението, в което изобщо няма да има достъп до ключовете. Ние автоматично ще прехвърлим разширението в заключено състояние поради изчакване.

Mobx ви позволява да съхранявате само минимален набор от данни, а останалите се изчисляват автоматично въз основа на тях. Това са така наречените изчислени свойства. Те могат да бъдат сравнени с изгледи в бази данни:

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

Сега съхраняваме само криптираните ключове и парола. Всичко останало е изчислено. Извършваме прехвърлянето към заключено състояние, като премахваме паролата от състоянието. Публичният API вече има метод за инициализиране на хранилището.

Написано за криптиране помощни програми, използващи crypto-js:

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

Браузърът има неактивен API, чрез който можете да се абонирате за промени в състояние на събитие. Държава, съответно, може да бъде idle, active и locked. За idle можете да зададете таймаут, а locked се задава, когато самата ОС е блокирана. Ще променим и селектора за запазване в 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)
        }
    }
}

Кодът преди тази стъпка е тук.

сделки

И така, стигаме до най-важното: създаване и подписване на транзакции в блокчейна. Ще използваме блокчейна и библиотеката на WAVES вълни-транзакции.

Първо, нека добавим към състоянието масив от съобщения, които трябва да бъдат подписани, след това добавете методи за добавяне на ново съобщение, потвърждаване на подписа и отказ:

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

    ...
}

Когато получим ново съобщение, ние добавяме метаданни към него, направете observable и добавете към store.messages.

Ако не го направите observable ръчно, тогава mobx ще го направи сам, когато добавя съобщения към масива. Той обаче ще създаде нов обект, към който няма да имаме препратка, но ще ни трябва за следващата стъпка.

След това връщаме обещание, което се разрешава, когато състоянието на съобщението се промени. Състоянието се следи от реакция, която ще се „убие“ при промяна на състоянието.

Код на метода approve и reject много просто: просто променяме статуса на съобщението, след като го подпишем, ако е необходимо.

Поставяме Одобрение и отхвърляне в API на потребителския интерфейс, ново съобщение в 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)
        }
    }

    ...
}

Сега нека се опитаме да подпишем транзакцията с разширението:

Писане на защитено разширение за браузър

Като цяло всичко е готово, остава само добавете прост потребителски интерфейс.

UI

Интерфейсът се нуждае от достъп до състоянието на приложението. От страна на потребителския интерфейс ще направим observable състояние и добавете функция към API, която ще промени това състояние. Нека добавим observable към API обекта, получен от заден план:

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

Накрая започваме да изобразяваме интерфейса на приложението. Това е приложение за реакция. Фоновият обект просто се предава с помощта на подпори. Би било правилно, разбира се, да се направи отделна услуга за методи и магазин за държавата, но за целите на тази статия това е достатъчно:

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

С mobx е много лесно да започнете рендиране, когато данните се променят. Ние просто окачваме декоратора на наблюдателя от опаковката mobx-реагират върху компонента и рендирането ще бъде извикано автоматично, когато се променят наблюдаемите, посочени от компонента. Нямате нужда от mapStateToProps или свързване като в redux. Всичко работи веднага след изваждането от кутията:

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

Останалите компоненти могат да се видят в кода в папката UI.

Сега в класа на приложението трябва да направите селектор на състояние за потребителския интерфейс и да уведомите потребителския интерфейс, когато се промени. За да направите това, нека добавим метод getState и reactionобаждане 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())

        })
    }

    ...
}

При получаване на обект remote е създаден reaction за да промените състоянието, което извиква функцията от страна на потребителския интерфейс.

Последното докосване е да добавите показването на нови съобщения върху иконата на разширението:

function setupApp() {
...

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

...
}

И така, приложението е готово. Уеб страниците могат да изискват подпис за транзакции:

Писане на защитено разширение за браузър

Писане на защитено разширение за браузър

Кодът е достъпен тук връзка.

Заключение

Ако сте прочели статията до края, но все още имате въпроси, можете да ги зададете на хранилища с разширение. Там ще намерите и ангажименти за всяка определена стъпка.

И ако се интересувате да разгледате кода за действителното разширение, можете да намерите това тук.

Код, хранилище и длъжностна характеристика от симарел

Източник: www.habr.com

Добавяне на нов коментар