Коопсуз браузер кеңейтүүсүн жазуу

Коопсуз браузер кеңейтүүсүн жазуу

Кадимки "кардар-сервер" архитектурасынан айырмаланып, борбордон ажыратылган тиркемелер төмөнкүлөр менен мүнөздөлөт:

  • Колдонуучунун логиндери жана сырсөздөрү менен маалымат базасын сактоонун кереги жок. Кирүү маалыматы колдонуучулардын өзүндө гана сакталат жана алардын аныктыгын ырастоо протокол деңгээлинде ишке ашат.
  • Серверди колдонуунун кереги жок. Колдонмонун логикасы блокчейн тармагында аткарылышы мүмкүн, анда керектүү көлөмдөгү маалыматтарды сактоого болот.

Колдонуучу ачкычтары үчүн 2 салыштырмалуу коопсуз сактагыч бар - аппараттык капчыктар жана серепчи кеңейтүүлөрү. Аппараттык капчыктар негизинен өтө коопсуз, бирок колдонуу кыйын жана бекер эмес, бирок браузердин кеңейтүүлөрү коопсуздуктун жана колдонуунун жөнөкөйлүгүнүн эң сонун айкалышы жана акыркы колдонуучулар үчүн толугу менен акысыз болушу мүмкүн.

Мунун баарын эске алып, биз транзакциялар жана кол тамгалар менен иштөө үчүн жөнөкөй API камсыз кылуу менен борбордон ажыратылган тиркемелерди иштеп чыгууну жөнөкөйлөткөн эң коопсуз кеңейтүүнү жасагыбыз келди.
Бул тажрыйба тууралуу төмөндө айтып беребиз.

Макалада код мисалдары жана скриншоттор менен браузердин кеңейтүүсүн кантип жазуу керектиги боюнча этап-этабы менен көрсөтмөлөр камтылат. Сиз бардык кодду таба аласыз репозиторийлер. Ар бир тапшырма логикалык жактан ушул макаланын бир бөлүгүнө туура келет.

Браузердик кеңейтүүлөрдүн кыскача тарыхы

Браузер кеңейтүүлөрү көптөн бери эле бар. Алар 1999-жылы Internet Explorerде, 2004-жылы Firefoxто пайда болгон. Бирок, узак убакыт бою кеңейтүүлөр үчүн бирдиктүү стандарт болгон эмес.

Бул 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. content_scripts — ар кандай веб-баракчаларга ар кандай скрипттерди жүктөөгө мүмкүндүк берүүчү объекттердин массивдери. Ар бир объект төмөнкү маанилүү талааларды камтыйт:
    1. дан - үлгү url, бул белгилүү бир мазмун скриптинин камтыла тургандыгын же кирбешин аныктайт.
    2. js — бул матчка жүктөлө турган сценарийлердин тизмеси;
    3. матчтарды_чыгаруу - талаадан чыгарат match Бул талаага дал келген URL'дер.
  3. page_action - бул чындыгында браузердеги дарек тилкесинин жанында көрсөтүлгөн сөлөкөт жана аны менен өз ара аракеттенүү үчүн жооптуу объект. Ал ошондой эле өз HTML, CSS жана JS аркылуу аныкталган калкыма терезени көрсөтүүгө мүмкүндүк берет.
    1. default_pupup — калкыма интерфейси бар HTML файлына жол, CSS жана JS камтышы мүмкүн.
  4. уруксаттар — кеңейтүү укуктарын башкаруу үчүн массив. Укуктардын 3 түрү бар, алар кеңири баяндалган бул жерде
  5. web_accessible_resources — веб-баракча сурай ала турган кеңейтүү ресурстары, мисалы, сүрөттөр, 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 ичинде жүктү unpacked жана тиешелүү кеңейтүүсү бар папканы тандоо керек - биздин учурда 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()
    }
}

Эми UIде байланыш түзөлү:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import Dnode from 'dnode/browser';

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

setupUi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Андан кийин биз мазмун скриптинде байланышты түзөбүз:

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

Эми биз inpage ичинде 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 менен Remote Procedure Call (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. Бош жүрүү үчүн сиз күтүү убактысын орното аласыз, ал эми кулпуланган OS өзү бөгөттөлгөндө коюлат. Ошондой эле жергиликтүү сактагычка сактоо үчүн селекторду өзгөртөбүз:

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-react Компонентте жана рендерлөө компонент тарабынан шилтеме кылынган бардык байкоолор өзгөргөндө автоматтык түрдө чакырылат. Сизге 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}
    );

...
}

Ошентип, арыз даяр. Веб баракчалар транзакциялар үчүн кол коюуну талап кылышы мүмкүн:

Коопсуз браузер кеңейтүүсүн жазуу

Коопсуз браузер кеңейтүүсүн жазуу

Код бул жерде жеткиликтүү байланыш.

жыйынтыктоо

Эгер сиз макаланы аягына чейин окуп чыккан болсоңуз, бирок дагы эле суроолоруңуз болсо, анда аларга кайрылсаңыз болот кеңейтүү менен репозиторийлер. Ал жерден ошондой эле ар бир белгиленген кадам үчүн милдеттенмелерди таба аласыз.

Эгер сиз чыныгы кеңейтүүнүн кодун карап көргүңүз келсе, муну таба аласыз бул жерде.

Код, репозиторий жана жумуш сүрөттөмөсү siemarell

Source: www.habr.com

Комментарий кошуу