Қауіпсіз браузер кеңейтімін жазу

Қауіпсіз браузер кеңейтімін жазу

Жалпы «клиент-сервер» архитектурасынан айырмашылығы, орталықтандырылмаған қолданбалар мыналармен сипатталады:

  • Пайдаланушы логиндері мен құпия сөздерімен дерекқорды сақтаудың қажеті жоқ. Қол жеткізу ақпараты тек пайдаланушылардың өздерінде сақталады және олардың түпнұсқалығын растау протокол деңгейінде жүзеге асырылады.
  • Серверді пайдаланудың қажеті жоқ. Қолданба логикасын блокчейн желісінде орындауға болады, онда деректердің қажетті көлемін сақтауға болады.

Пайдаланушы кілттері үшін салыстырмалы түрде қауіпсіз 2 сақтау орны бар - аппараттық әмияндар және шолғыш кеңейтімдері. Аппараттық әмияндар негізінен өте қауіпсіз, бірақ пайдалану қиын және тегін емес, бірақ шолғыш кеңейтімдері қауіпсіздік пен пайдаланудың қарапайымдылығының тамаша үйлесімі болып табылады және соңғы пайдаланушылар үшін толығымен тегін болуы мүмкін.

Осының бәрін ескере отырып, біз транзакциялармен және қолтаңбалармен жұмыс істеу үшін қарапайым API ұсына отырып, орталықтандырылмаған қосымшаларды әзірлеуді жеңілдететін ең қауіпсіз кеңейтімді жасағымыз келді.
Бұл тәжірибе туралы төменде айтып береміз.

Мақалада код мысалдары мен скриншоттары бар браузер кеңейтімін қалай жазу керектігі туралы қадамдық нұсқаулар болады. Сіз барлық кодты таба аласыз репозиторийлер. Әрбір тапсырма логикалық түрде осы мақаланың бір бөліміне сәйкес келеді.

Браузер кеңейтімдерінің қысқаша тарихы

Браузер кеңейтімдері бұрыннан бар. Олар Internet Explorer-де 1999 жылы, Firefox-та 2004 жылы пайда болды. Дегенмен, өте ұзақ уақыт бойы кеңейтімдердің бірыңғай стандарты болмады.

Ол Google Chrome төртінші нұсқасында кеңейтімдермен бірге пайда болды деп айта аламыз. Әрине, ол кезде ешқандай спецификация болған жоқ, бірақ оның негізі Chrome API болды: браузер нарығының көп бөлігін жаулап алып, кірістірілген қолданбалар дүкеніне ие бола отырып, Chrome браузер кеңейтімдері үшін стандартты орнатты.

Mozilla-ның өз стандарты болды, бірақ Chrome кеңейтімдерінің танымалдылығын көріп, компания үйлесімді API жасауға шешім қабылдады. 2015 жылы Mozilla-ның бастамасымен Бүкіләлемдік Интернет консорциумында (W3C) кросс-браузер кеңейтімінің техникалық сипаттамаларымен жұмыс істеу үшін арнайы топ құрылды.

Chrome үшін бар API кеңейтімдері негізге алынды. Жұмыс Microsoft корпорациясының қолдауымен жүзеге асырылды (Google стандартты әзірлеуге қатысудан бас тартты), нәтижесінде жоба пайда болды. сипаттамалары.

Ресми түрде спецификацияға Edge, Firefox және Opera қолдау көрсетеді (Chrome бұл тізімде жоқ екенін ескеріңіз). Бірақ шын мәнінде, стандарт негізінен Chrome-мен үйлесімді, өйткені ол іс жүзінде оның кеңейтімдері негізінде жазылған. WebExtensions API туралы қосымша оқуға болады осында.

Кеңейтім құрылымы

Кеңейтім үшін талап етілетін жалғыз файл - манифест (manifest.json). Бұл сонымен қатар кеңейтудің «кіру нүктесі».

Манифест

Техникалық сипаттамаға сәйкес, манифест файлы жарамды JSON файлы болып табылады. Қай браузерде қай пернелерге қолдау көрсетілетіні туралы ақпараты бар манифест кілттерінің толық сипаттамасы осында.

Спецификацияда жоқ кілттерді елемеу мүмкін (Chrome және Firefox екеуі де қателер туралы хабарлайды, бірақ кеңейтімдер жұмысын жалғастыруда).

Және кейбір жайттарға назар аударғым келеді.

  1. фон — келесі өрістерді қамтитын объект:
    1. сценарийлер — фондық контексте орындалатын сценарийлер массиві (бұл туралы сәл кейінірек айтатын боламыз);
    2. бет - бос бетте орындалатын сценарийлердің орнына мазмұны бар html көрсетуге болады. Бұл жағдайда сценарий өрісі еленбейді және сценарийлерді мазмұн бетіне кірістіру қажет болады;
    3. тайсалмау — екілік жалауша, егер көрсетілмесе, браузер ештеңе істемейді деп есептегенде, фондық процесті «өлтіреді» және қажет болған жағдайда оны қайта іске қосады. Әйтпесе, бет браузер жабылған кезде ғана жүктеледі. Firefox-та қолдау көрсетілмейді.
  2. мазмұн_скрипттері — әртүрлі веб-беттерге әртүрлі сценарийлерді жүктеуге мүмкіндік беретін объектілер массиві. Әрбір нысанда келесі маңызды өрістер бар:
    1. матчтар - үлгі url, ол белгілі бір мазмұн сценарийінің қосылатынын немесе қосылмайтынын анықтайды.
    2. js — осы сәйкестікке жүктелетін сценарийлер тізімі;
    3. сәйкестіктерді_шығару - өрістен шығарылады match Осы өріске сәйкес URL мекенжайлары.
  3. бет_әрекеті - шын мәнінде браузердегі мекенжай жолағының жанында көрсетілетін белгішеге және онымен әрекеттесу үшін жауап беретін нысан. Ол сондай-ақ жеке HTML, CSS және JS көмегімен анықталған қалқымалы терезені көрсетуге мүмкіндік береді.
    1. әдепкі_қалқымалы терезе — қалқымалы интерфейсі бар HTML файлының жолы, құрамында CSS және JS болуы мүмкін.
  4. рұқсаттар — кеңейтім құқықтарын басқаруға арналған массив. Құқықтың 3 түрі бар, олар егжей-тегжейлі сипатталған осында
  5. web_accessible_sources — веб-бет сұрауға болатын кеңейтім ресурстары, мысалы, суреттер, 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). Төменде 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))
    }
)

Толық байланыс үшін сіз арқылы қосылымдар жасауға болады 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-ға жүктеп салуға болады және оның жұмыс істейтініне көз жеткізіңіз.

Мұны тексеру үшін кодты алуға болады мұнда. Біз жасаған әрекеттерге қосымша, сілтеме веб-пакет арқылы жобаның құрастырылуын конфигурациялады. Браузерге қолданба қосу үшін chrome://extensions ішінде жүкті жүктеп алуды және сәйкес кеңейтімі бар қалтаны таңдау керек - біздің жағдайда dist.

Қауіпсіз браузер кеңейтімін жазу

Қазір кеңейтіміміз орнатылып, жұмыс істеп тұр. Әртүрлі мәтінмәндер үшін әзірлеуші ​​құралдарын келесідей іске қосуға болады:

қалқымалы терезе ->

Қауіпсіз браузер кеңейтімін жазу

Мазмұн сценарийінің консоліне кіру ол іске қосылған беттің консолі арқылы жүзеге асырылады.Қауіпсіз браузер кеңейтімін жазу

Хабар алмасу

Сонымен, бізге екі байланыс арнасын орнату керек: беттегі <-> фон және қалқымалы <-> фон. Сіз, әрине, жай ғана портқа хабарламалар жібере аласыз және өз хаттамаңызды ойлап таба аласыз, бірақ мен метамасктың ашық бастапқы жобасында көрген тәсілді ұнатамын.

Бұл Ethereum желісімен жұмыс істеуге арналған шолғыш кеңейтімі. Онда қолданбаның әртүрлі бөліктері dnode кітапханасы арқылы RPC арқылы байланысады. Ол алмасуды өте тез және ыңғайлы ұйымдастыруға мүмкіндік береді, егер сіз оны көлік ретінде 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 нысанының орнына Google браузерінде және басқаларында браузерде Chrome браузеріне кіретін extensionApi қолданамыз. Бұл браузерлер арасындағы үйлесімділік үшін жасалады, бірақ осы мақаланың мақсаттары үшін жай ғана '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 ағындармен жұмыс істейтіндіктен және біз портты алатындықтан, адаптер класы қажет. Ол шолғышта 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 нысанын жасаймыз және оны жаһандық етіп орнатамыз:

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

Біз дайынбыз Бет пен UI үшін бөлек API бар қашықтағы процедураны шақыру (RPC).. Жаңа бетті фонға қосқанда біз мынаны көреміз:

Қауіпсіз браузер кеңейтімін жазу

Бос API және бастапқы. Бет жағында біз hello функциясын келесідей шақыра аламыз:

Қауіпсіз браузер кеңейтімін жазу

Қазіргі JS жүйесінде кері шақыру функцияларымен жұмыс істеу дұрыс емес, сондықтан API нысанын utils-ге беруге мүмкіндік беретін dnode жасау үшін шағын көмекші жазайық.

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 RPC 2 протоколын жүзеге асыратын JSON пішімі болып табылады.Бірақ ол нақты тасымалдаулармен (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-де сақтаймыз, әр өзгеріс сайын қайта жазамыз. Кейіннен оған қол жеткізу UI үшін де қажет болады, мен де өзгерістерге жазылғым келеді. Осының негізінде бақыланатын жадты құру және оның өзгерістеріне жазылу ыңғайлы болады.

Біз 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. Бос тұру үшін күту уақытын орнатуға болады, ал құлыптау ОС өзі бұғатталған кезде орнатылады. Сондай-ақ біз 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 өте қарапайым: қажет болған жағдайда қол қойғаннан кейін хабардың күйін өзгертеміз.

Біз UI API интерфейсінде "Мақұлдау" және "қабылдамау", API бетінде newMessage қойылады:

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 қосыңыз.

UI

Интерфейс қолданба күйіне кіруді қажет етеді. 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 қалтасында.

Енді қолданбалар сыныбында UI үшін күй селекторын жасау керек және ол өзгерген кезде 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 UI жағында функцияны шақыратын күйді өзгерту үшін.

Соңғы түрту кеңейтім белгішесіне жаңа хабарларды көрсетуді қосу болып табылады:

function setupApp() {
...

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

...
}

Сонымен, өтінім дайын. Веб-беттер транзакциялар үшін қол қоюды сұрауы мүмкін:

Қауіпсіз браузер кеңейтімін жазу

Қауіпсіз браузер кеңейтімін жазу

Код осында қол жетімді байланыс.

қорытынды

Егер сіз мақаланы соңына дейін оқысаңыз, бірақ әлі де сұрақтарыңыз болса, оларды мына мекен-жайда қоюға болады кеңейтімі бар репозиторийлер. Мұнда сіз әрбір тағайындалған қадам үшін міндеттемелерді таба аласыз.

Егер сіз нақты кеңейтімнің кодын көргіңіз келсе, оны таба аласыз осында.

Код, репозиторий және жұмыс сипаттамасы Симарелл

Ақпарат көзі: www.habr.com

пікір қалдыру