Pagsusulat ng isang secure na extension ng browser

Pagsusulat ng isang secure na extension ng browser

Hindi tulad ng karaniwang arkitektura ng "client-server", ang mga desentralisadong aplikasyon ay nailalarawan sa pamamagitan ng:

  • Hindi na kailangang mag-imbak ng database na may mga login at password ng user. Ang impormasyon sa pag-access ay eksklusibong iniimbak ng mga gumagamit mismo, at ang pagkumpirma ng kanilang pagiging tunay ay nangyayari sa antas ng protocol.
  • Hindi na kailangang gumamit ng server. Ang logic ng application ay maaaring isagawa sa isang blockchain network, kung saan posible na mag-imbak ng kinakailangang dami ng data.

Mayroong 2 medyo ligtas na imbakan para sa mga susi ng user - mga wallet ng hardware at mga extension ng browser. Ang mga wallet ng hardware ay halos napaka-secure, ngunit mahirap gamitin at malayo sa libre, ngunit ang mga extension ng browser ay ang perpektong kumbinasyon ng seguridad at kadalian ng paggamit, at maaari ding maging ganap na libre para sa mga end user.

Isinasaalang-alang ang lahat ng ito, nais naming gawin ang pinakasecure na extension na nagpapasimple sa pagbuo ng mga desentralisadong aplikasyon sa pamamagitan ng pagbibigay ng simpleng API para sa pagtatrabaho sa mga transaksyon at mga lagda.
Sasabihin namin sa iyo ang tungkol sa karanasang ito sa ibaba.

Maglalaman ang artikulo ng mga sunud-sunod na tagubilin sa kung paano magsulat ng extension ng browser, na may mga halimbawa ng code at mga screenshot. Mahahanap mo ang lahat ng code sa mga repositoryo. Ang bawat commit ay lohikal na tumutugma sa isang seksyon ng artikulong ito.

Isang Maikling Kasaysayan ng Mga Extension ng Browser

Matagal nang umiiral ang mga extension ng browser. Lumitaw sila sa Internet Explorer noong 1999, sa Firefox noong 2004. Gayunpaman, sa loob ng napakahabang panahon ay walang iisang pamantayan para sa mga extension.

Masasabi nating lumabas ito kasama ng mga extension sa ikaapat na bersyon ng Google Chrome. Siyempre, walang detalye noon, ngunit ang Chrome API ang naging batayan nito: na nasakop ang karamihan sa market ng browser at may built-in na application store, talagang itinakda ng Chrome ang pamantayan para sa mga extension ng browser.

May sariling pamantayan ang Mozilla, ngunit dahil nakikita ang katanyagan ng mga extension ng Chrome, nagpasya ang kumpanya na gumawa ng isang katugmang API. Noong 2015, sa inisyatiba ng Mozilla, isang espesyal na grupo ang nilikha sa loob ng World Wide Web Consortium (W3C) upang magtrabaho sa mga detalye ng cross-browser extension.

Ang umiiral na mga extension ng API para sa Chrome ay kinuha bilang batayan. Isinagawa ang gawain sa suporta ng Microsoft (tumanggi ang Google na lumahok sa pagbuo ng pamantayan), at bilang isang resulta ay lumitaw ang isang draft mga pagtutukoy.

Pormal, ang detalye ay sinusuportahan ng Edge, Firefox at Opera (tandaan na ang Chrome ay wala sa listahang ito). Ngunit sa katunayan, ang pamantayan ay higit na katugma sa Chrome, dahil ito ay aktwal na nakasulat batay sa mga extension nito. Maaari kang magbasa nang higit pa tungkol sa WebExtensions API dito.

Istraktura ng extension

Ang tanging file na kinakailangan para sa extension ay ang manifest (manifest.json). Ito rin ang "entry point" sa pagpapalawak.

Manifest

Ayon sa detalye, ang manifest file ay isang wastong JSON file. Isang buong paglalarawan ng mga manifest key na may impormasyon tungkol sa kung aling mga key ang sinusuportahan kung saan maaaring tingnan ang browser dito.

Ang mga susi na wala sa detalyeng "maaaring" ay balewalain (parehong nag-uulat ang Chrome at Firefox ng mga error, ngunit patuloy na gumagana ang mga extension).

At nais kong bigyang pansin ang ilang mga punto.

  1. likuran — isang bagay na kinabibilangan ng mga sumusunod na field:
    1. script — isang hanay ng mga script na isasagawa sa konteksto ng background (pag-uusapan natin ito sa ibang pagkakataon);
    2. pahina — sa halip na mga script na isasagawa sa isang walang laman na pahina, maaari mong tukuyin ang html na may nilalaman. Sa kasong ito, ang script field ay hindi papansinin at ang mga script ay kailangang ipasok sa pahina ng nilalaman;
    3. magpumilit — isang binary flag, kung hindi tinukoy, "papatayin" ng browser ang proseso sa background kapag isinasaalang-alang nito na wala itong ginagawa, at i-restart ito kung kinakailangan. Kung hindi, maa-unload lang ang page kapag sarado na ang browser. Hindi suportado sa Firefox.
  2. content_scripts — isang hanay ng mga bagay na nagbibigay-daan sa iyong mag-load ng iba't ibang mga script sa iba't ibang mga web page. Ang bawat bagay ay naglalaman ng mga sumusunod na mahahalagang field:
    1. posporo - pattern na url, na tumutukoy kung ang isang partikular na script ng nilalaman ay isasama o hindi.
    2. js — isang listahan ng mga script na ilo-load sa laban na ito;
    3. exclude_matches - hindi kasama sa field match Mga URL na tumutugma sa field na ito.
  3. page_action - ay talagang isang bagay na responsable para sa icon na ipinapakita sa tabi ng address bar sa browser at pakikipag-ugnayan dito. Nagbibigay-daan din ito sa iyo na magpakita ng popup window, na tinukoy gamit ang sarili mong HTML, CSS at JS.
    1. default_popup — path sa HTML file na may popup interface, maaaring naglalaman ng CSS at JS.
  4. mga pahintulot — isang array para sa pamamahala ng mga karapatan sa extension. Mayroong 3 uri ng mga karapatan, na inilarawan nang detalyado dito
  5. web_accessible_resources — mga mapagkukunan ng extension na maaaring hilingin ng isang web page, halimbawa, mga larawan, JS, CSS, HTML file.
  6. externally_connectable — dito maaari mong tahasang tukuyin ang mga ID ng iba pang mga extension at domain ng mga web page kung saan maaari kang kumonekta. Ang isang domain ay maaaring pangalawang antas o mas mataas. Hindi gumagana sa Firefox.

Konteksto ng pagpapatupad

Ang extension ay may tatlong konteksto ng pagpapatupad ng code, iyon ay, ang application ay binubuo ng tatlong bahagi na may iba't ibang antas ng access sa browser API.

Konteksto ng extension

Karamihan sa API ay available dito. Sa kontekstong ito sila ay "nabubuhay":

  1. Pahina sa background — "backend" na bahagi ng extension. Tinukoy ang file sa manifest gamit ang "background" key.
  2. Popup page — isang popup page na lalabas kapag nag-click ka sa icon ng extension. Sa manifesto browser_action -> default_popup.
  3. Pasadyang pahina — pahina ng extension, "nakatira" sa isang hiwalay na tab ng view chrome-extension://<id_расширения>/customPage.html.

Ang kontekstong ito ay umiiral nang hiwalay sa mga window at tab ng browser. Pahina sa background umiiral sa isang kopya at palaging gumagana (ang exception ay ang page ng kaganapan, kapag ang background script ay inilunsad ng isang kaganapan at "namatay" pagkatapos ng pagpapatupad nito). Popup page umiiral kapag nakabukas ang popup window, at Pasadyang pahina — habang nakabukas ang tab na kasama nito. Walang access sa iba pang mga tab at sa kanilang mga nilalaman mula sa kontekstong ito.

Konteksto ng script ng nilalaman

Inilunsad ang content script file kasama ng bawat tab ng browser. Mayroon itong access sa bahagi ng API ng extension at sa DOM tree ng web page. Ito ay mga script ng nilalaman na responsable para sa pakikipag-ugnayan sa pahina. Ginagawa ito ng mga extension na nagmamanipula sa puno ng DOM sa mga script ng nilalaman - halimbawa, mga ad blocker o tagasalin. Gayundin, ang script ng nilalaman ay maaaring makipag-ugnayan sa pahina sa pamamagitan ng pamantayan postMessage.

Konteksto ng web page

Ito ang aktwal na web page mismo. Wala itong kinalaman sa extension at walang access doon, maliban sa mga kaso kung saan ang domain ng page na ito ay hindi tahasang ipinahiwatig sa manifest (higit pa dito sa ibaba).

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

Ang iba't ibang bahagi ng application ay dapat makipagpalitan ng mga mensahe sa bawat isa. Mayroong isang API para dito runtime.sendMessage para magpadala ng mensahe background и tabs.sendMessage upang magpadala ng mensahe sa isang pahina (script ng nilalaman, popup o web page kung magagamit externally_connectable). Nasa ibaba ang isang halimbawa kapag ina-access ang 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))
    }
)

Para sa buong komunikasyon, maaari kang lumikha ng mga koneksyon sa pamamagitan ng runtime.connect. Bilang tugon ay matatanggap namin runtime.Port, kung saan, habang bukas ito, maaari kang magpadala ng anumang bilang ng mga mensahe. Sa panig ng kliyente, halimbawa, contentscript, ganito ang hitsura:

// Опять же extensionId можно не указывать при коммуникации внутри одного расширения. Подключение можно именовать
const port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
    if (msg.question === "Who's there?")
        port.postMessage({answer: "Madame"});
    else if (msg.question === "Madame who?")
        port.postMessage({answer: "Madame... Bovary"});

Server o background:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, popup или страниц расширения
chrome.runtime.onConnect.addListener(function(port) {
    console.assert(port.name === "knockknock");
    port.onMessage.addListener(function(msg) {
        if (msg.joke === "Knock knock")
            port.postMessage({question: "Who's there?"});
        else if (msg.answer === "Madame")
            port.postMessage({question: "Madame who?"});
        else if (msg.answer === "Madame... Bovary")
            port.postMessage({question: "I don't get it."});
    });
});

// Обработчик для подключения внешних вкладок. Других расширений или веб страниц, которым разрешен доступ в манифесте
chrome.runtime.onConnectExternal.addListener(function(port) {
    ...
});

May event din onDisconnect at pamamaraan disconnect.

Diagram ng aplikasyon

Gumawa tayo ng extension ng browser na nag-iimbak ng mga pribadong key, nagbibigay ng access sa pampublikong impormasyon (address, pampublikong key na nakikipag-ugnayan sa page at nagbibigay-daan sa mga third-party na application na humiling ng lagda para sa mga transaksyon.

Pag-unlad ng aplikasyon

Ang aming application ay dapat na parehong makipag-ugnayan sa user at magbigay sa pahina ng isang API para sa pagtawag (halimbawa, upang mag-sign ng mga transaksyon). Gumawa ng gawin sa isa lamang contentscript ay hindi gagana, dahil mayroon lamang itong access sa DOM, ngunit hindi sa JS ng page. Kumonekta sa pamamagitan ng runtime.connect hindi namin magagawa, dahil kailangan ang API sa lahat ng domain, at mga partikular lang ang maaaring tukuyin sa manifest. Bilang resulta, ang diagram ay magiging ganito:

Pagsusulat ng isang secure na extension ng browser

Magkakaroon ng isa pang script - inpage, na aming i-inject sa page. Tatakbo ito sa konteksto nito at magbibigay ng API para sa pagtatrabaho sa extension.

simula

Lahat ng browser extension code ay available sa GitHub. Sa panahon ng paglalarawan magkakaroon ng mga link sa mga commit.

Magsimula tayo sa manifesto:

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

Lumikha ng walang laman na background.js, popup.js, inpage.js at contentscript.js. Nagdagdag kami ng popup.html - at ang aming application ay maaari nang mai-load sa Google Chrome at tiyaking gumagana ito.

Para ma-verify ito, maaari mong kunin ang code kaya. Bilang karagdagan sa aming ginawa, na-configure ng link ang pagpupulong ng proyekto gamit ang webpack. Upang magdagdag ng isang application sa browser, sa chrome://extensions kailangan mong piliin ang load unpacked at ang folder na may kaukulang extension - sa aming kaso dist.

Pagsusulat ng isang secure na extension ng browser

Ngayon ang aming extension ay naka-install at gumagana. Maaari mong patakbuhin ang mga tool ng developer para sa iba't ibang konteksto gaya ng sumusunod:

popup ->

Pagsusulat ng isang secure na extension ng browser

Ang pag-access sa content script console ay isinasagawa sa pamamagitan ng console ng mismong page kung saan ito inilunsad.Pagsusulat ng isang secure na extension ng browser

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

Kaya, kailangan nating magtatag ng dalawang channel ng komunikasyon: inpage <-> background at popup <-> background. Maaari kang, siyempre, magpadala lamang ng mga mensahe sa port at mag-imbento ng iyong sariling protocol, ngunit mas gusto ko ang diskarte na nakita ko sa metamask open source na proyekto.

Ito ay isang extension ng browser para sa pagtatrabaho sa Ethereum network. Sa loob nito, ang iba't ibang bahagi ng application ay nakikipag-usap sa pamamagitan ng RPC gamit ang dnode library. Pinapayagan ka nitong ayusin ang isang palitan nang mabilis at maginhawa kung bibigyan mo ito ng isang nodejs stream bilang isang transportasyon (ibig sabihin ay isang bagay na nagpapatupad ng parehong interface):

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

Ngayon ay lilikha tayo ng klase ng aplikasyon. Lilikha ito ng mga object ng API para sa popup at web page, at lilikha ng dnode para sa kanila:

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

Dito at sa ibaba, sa halip na ang pangkalahatang Chrome object, ginagamit namin ang extensionApi, na nag-a-access sa Chrome sa browser ng Google at browser sa iba. Ginagawa ito para sa cross-browser compatibility, ngunit para sa mga layunin ng artikulong ito ay magagamit lang ng isa ang 'chrome.runtime.connect'.

Gumawa tayo ng instance ng application sa background script:

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

Dahil gumagana ang dnode sa mga stream, at nakatanggap kami ng port, kailangan ng klase ng adaptor. Ginagawa ito gamit ang readable-stream library, na nagpapatupad ng mga nodejs stream sa browser:

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

Ngayon gumawa tayo ng koneksyon sa 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;
    }
}

Pagkatapos ay gagawa kami ng koneksyon sa script ng nilalaman:

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

Dahil kailangan namin ang API wala sa script ng nilalaman, ngunit direkta sa pahina, ginagawa namin ang dalawang bagay:

  1. Lumilikha kami ng dalawang stream. Isa - patungo sa pahina, sa ibabaw ng postMessage. Para dito ginagamit namin ito paketeng ito mula sa mga tagalikha ng metamask. Ang pangalawang stream ay sa background sa ibabaw ng port na natanggap mula sa runtime.connect. Bilhin natin sila. Ngayon ang page ay magkakaroon ng stream sa background.
  2. Ipasok ang script sa DOM. I-download ang script (pinayagan ang pag-access dito sa manifest) at gumawa ng tag script kasama ang mga nilalaman nito sa loob:

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

Ngayon ay lumikha kami ng isang api object sa inpage at itinakda ito sa global:

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

Handa na kami Remote Procedure Call (RPC) na may hiwalay na API para sa page at UI. Kapag kumokonekta ng bagong pahina sa background, makikita natin ito:

Pagsusulat ng isang secure na extension ng browser

Walang laman ang API at pinagmulan. Sa gilid ng pahina, maaari nating tawagan ang hello function na tulad nito:

Pagsusulat ng isang secure na extension ng browser

Ang pagtatrabaho sa mga function ng callback sa modernong JS ay masamang asal, kaya sumulat tayo ng isang maliit na katulong upang lumikha ng isang dnode na nagbibigay-daan sa iyong magpasa ng isang object ng API sa mga util.

Magiging ganito na ngayon ang mga object ng API:

export class SignerApp {

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

...

}

Pagkuha ng isang bagay mula sa remote tulad nito:

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

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

At ang mga function ng pagtawag ay nagbabalik ng isang pangako:

Pagsusulat ng isang secure na extension ng browser

Bersyon na may magagamit na mga asynchronous na function dito.

Sa pangkalahatan, ang RPC at stream na diskarte ay tila medyo nababaluktot: maaari naming gamitin ang steam multiplexing at lumikha ng ilang iba't ibang mga API para sa iba't ibang mga gawain. Sa prinsipyo, ang dnode ay maaaring gamitin kahit saan, ang pangunahing bagay ay ang balutin ang transportasyon sa anyo ng isang nodejs stream.

Ang isang alternatibo ay ang JSON format, na nagpapatupad ng JSON RPC 2 protocol. Gayunpaman, gumagana ito sa mga partikular na transport (TCP at HTTP(S)), na hindi naaangkop sa aming kaso.

Panloob na estado at localStorage

Kakailanganin naming iimbak ang panloob na estado ng application - hindi bababa sa mga signing key. Madali kaming makakapagdagdag ng estado sa application at mga pamamaraan para sa pagbabago nito sa popup API:

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

export class SignerApp {

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

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

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

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

    ...

} 

Sa background, ibalot namin ang lahat sa isang function at isusulat ang object ng application sa window para magamit namin ito mula sa console:

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

Magdagdag tayo ng ilang key mula sa UI console at tingnan kung ano ang mangyayari sa estado:

Pagsusulat ng isang secure na extension ng browser

Ang estado ay kailangang gawing paulit-ulit upang ang mga susi ay hindi mawala kapag nag-restart.

Iimbak namin ito sa localStorage, papatungan ito sa bawat pagbabago. Sa dakong huli, kakailanganin din ang access dito para sa UI, at gusto ko ring mag-subscribe sa mga pagbabago. Batay dito, magiging maginhawa upang lumikha ng isang napapansin na imbakan at mag-subscribe sa mga pagbabago nito.

Gagamitin namin ang mobx library (https://github.com/mobxjs/mobx). Ang pagpili ay nahulog dito dahil hindi ko kailangang magtrabaho kasama ito, ngunit talagang gusto kong pag-aralan ito.

Idagdag natin ang pagsisimula ng paunang estado at gawin ang tindahan na mapapansin:

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

    ...

}

"Sa ilalim ng hood," pinalitan ng mobx ang lahat ng field ng store ng proxy at hinarang ang lahat ng tawag sa kanila. Magiging posible na mag-subscribe sa mga mensaheng ito.

Sa ibaba ay madalas kong gagamitin ang terminong "kapag nagbabago", bagaman hindi ito ganap na tama. Sinusubaybayan ng Mobx ang access sa mga field. Ang mga getter at setter ng mga proxy object na ginagawa ng library ay ginagamit.

Ang mga dekorador ng aksyon ay nagsisilbi ng dalawang layunin:

  1. Sa mahigpit na mode na may flag ng enforceActions, ipinagbabawal ng mobx ang direktang pagbabago sa estado. Ito ay itinuturing na magandang paraan upang magtrabaho sa ilalim ng mahigpit na mga kondisyon.
  2. Kahit na binago ng isang function ang estado ng ilang beses - halimbawa, binago namin ang ilang mga patlang sa ilang linya ng code - ang mga tagamasid ay aabisuhan lamang kapag ito ay nakumpleto. Ito ay lalong mahalaga para sa frontend, kung saan ang mga hindi kinakailangang pag-update ng estado ay humahantong sa hindi kinakailangang pag-render ng mga elemento. Sa aming kaso, hindi partikular na nauugnay ang una o ang pangalawa, ngunit susundin namin ang pinakamahuhusay na kagawian. Nakaugalian na ilakip ang mga dekorador sa lahat ng mga pag-andar na nagbabago sa estado ng mga naobserbahang patlang.

Sa background ay magdaragdag kami ng pagsisimula at pag-save ng estado sa 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)
        }
    }
}

Ang pag-andar ng reaksyon ay kawili-wili dito. Mayroon itong dalawang argumento:

  1. Tagapili ng data.
  2. Isang handler na tatawagin kasama ang data na ito sa tuwing magbabago ito.

Hindi tulad ng redux, kung saan tahasan naming tinatanggap ang estado bilang argumento, naaalala ng mobx kung aling mga obserbasyon ang naa-access namin sa loob ng selector, at tatawagan lang ang handler kapag nagbago ang mga ito.

Mahalagang maunawaan nang eksakto kung paano nagpapasya ang mobx kung aling mga observable ang aming naka-subscribe. Kung nagsulat ako ng isang tagapili sa code na tulad nito() => app.store, kung gayon ang reaksyon ay hindi kailanman tatawagin, dahil ang imbakan mismo ay hindi nakikita, tanging ang mga patlang nito ang nakikita.

Kung ganito ang sinulat ko () => app.store.keys, at muli walang mangyayari, dahil kapag nagdadagdag/nag-aalis ng mga elemento ng array, hindi magbabago ang reference dito.

Nagsisilbing tagapili ang Mobx sa unang pagkakataon at sinusubaybayan lamang ang mga naoobserbahang na-access namin. Ginagawa ito sa pamamagitan ng mga proxy getter. Samakatuwid, ang built-in na function ay ginagamit dito toJS. Nagbabalik ito ng isang bagong bagay na ang lahat ng mga proxy ay pinalitan ng orihinal na mga patlang. Sa panahon ng pagpapatupad, binabasa nito ang lahat ng mga patlang ng bagay - samakatuwid ang mga getter ay na-trigger.

Sa popup console muli kaming magdagdag ng ilang mga susi. Sa pagkakataong ito nakapasok din sila sa localStorage:

Pagsusulat ng isang secure na extension ng browser

Kapag na-reload ang background page, nananatili ang impormasyon sa lugar.

Ang lahat ng code ng aplikasyon hanggang sa puntong ito ay maaaring matingnan dito.

Ligtas na imbakan ng mga pribadong key

Ang pag-imbak ng mga pribadong key sa malinaw na text ay hindi ligtas: palaging may pagkakataon na ikaw ay ma-hack, makakuha ng access sa iyong computer, at iba pa. Samakatuwid, sa localStorage iimbak namin ang mga susi sa isang form na naka-encrypt ng password.

Para sa higit na seguridad, magdaragdag kami ng naka-lock na estado sa application, kung saan walang magiging access sa mga susi. Awtomatiko naming ililipat ang extension sa naka-lock na estado dahil sa isang timeout.

Pinapayagan ka ng Mobx na mag-imbak lamang ng isang minimum na hanay ng data, at ang iba ay awtomatikong kinakalkula batay dito. Ito ang mga tinatawag na computed properties. Maihahambing ang mga ito sa mga view sa mga database:

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

Ngayon, iniimbak lamang namin ang mga naka-encrypt na key at password. Lahat ng iba pa ay kinakalkula. Ginagawa namin ang paglipat sa isang naka-lock na estado sa pamamagitan ng pag-alis ng password mula sa estado. Ang pampublikong API ay mayroon na ngayong paraan para sa pagsisimula ng storage.

Isinulat para sa pag-encrypt mga utility gamit ang 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)
}

Ang browser ay may idle na API kung saan maaari kang mag-subscribe sa isang kaganapan - mga pagbabago sa estado. Estado, nang naaayon, ay maaaring idle, active и locked. Para sa idle maaari kang magtakda ng timeout, at naka-lock ang nakatakda kapag ang OS mismo ay na-block. Papalitan din namin ang selector para sa pag-save sa 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)
        }
    }
}

Ang code bago ang hakbang na ito ay dito.

Mga Transaksyon

Kaya, dumating tayo sa pinakamahalagang bagay: paglikha at pagpirma ng mga transaksyon sa blockchain. Gagamitin natin ang WAVES blockchain at library alon-transaksyon.

Una, idagdag natin sa estado ang isang hanay ng mga mensahe na kailangang lagdaan, pagkatapos ay magdagdag ng mga pamamaraan para sa pagdaragdag ng bagong mensahe, pagkumpirma ng lagda, at pagtanggi:

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

    ...
}

Kapag nakatanggap kami ng bagong mensahe, nagdaragdag kami ng metadata dito, gawin observable at idagdag sa store.messages.

Kung hindi mo gagawin observable mano-mano, pagkatapos ay gagawin ito mismo ng mobx kapag nagdaragdag ng mga mensahe sa array. Gayunpaman, lilikha ito ng bagong bagay kung saan wala kaming reference, ngunit kakailanganin namin ito para sa susunod na hakbang.

Susunod, ibinabalik namin ang isang pangako na malulutas kapag nagbago ang status ng mensahe. Ang katayuan ay sinusubaybayan ng reaksyon, na "papatayin ang sarili" kapag nagbago ang katayuan.

Code ng pamamaraan approve и reject napakasimple: binabago lang namin ang katayuan ng mensahe, pagkatapos pirmahan ito kung kinakailangan.

Inilagay namin ang Approve at reject sa UI API, newMessage sa page API:

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

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

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

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

    ...
}

Ngayon subukan nating lagdaan ang transaksyon gamit ang extension:

Pagsusulat ng isang secure na extension ng browser

Sa pangkalahatan, handa na ang lahat, ang natitira ay magdagdag ng simpleng UI.

UI

Ang interface ay nangangailangan ng access sa estado ng application. Sa gilid ng UI gagawin natin observable estado at magdagdag ng isang function sa API na magbabago sa estado na ito. Dagdagan natin observable sa API object na natanggap mula sa background:

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

Sa dulo sinisimulan namin ang pag-render ng interface ng application. Ito ay isang react application. Ang background object ay ipinapasa lamang gamit ang props. Magiging tama, siyempre, na gumawa ng isang hiwalay na serbisyo para sa mga pamamaraan at isang tindahan para sa estado, ngunit para sa mga layunin ng artikulong ito ito ay sapat na:

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

Sa mobx, napakadaling magsimulang mag-render kapag nagbago ang data. Isinabit lang namin ang tagamasid na dekorador mula sa pakete mobx-react sa component, at awtomatikong tatawagin ang render kapag nagbago ang anumang mga observable na isinangguni ng component. Hindi mo kailangan ng anumang mapStateToProps o kumonekta tulad ng sa redux. Gumagana ang lahat sa labas ng kahon:

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

Ang natitirang mga bahagi ay maaaring matingnan sa code sa folder ng UI.

Ngayon sa klase ng aplikasyon kailangan mong gumawa ng tagapili ng estado para sa UI at abisuhan ang UI kapag nagbago ito. Upang gawin ito, magdagdag tayo ng isang paraan getState и reactiontumatawag 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())

        })
    }

    ...
}

Kapag tumatanggap ng isang bagay remote ay nilikha reaction upang baguhin ang estado na tumatawag sa function sa gilid ng UI.

Ang huling pagpindot ay ang magdagdag ng pagpapakita ng mga bagong mensahe sa icon ng extension:

function setupApp() {
...

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

...
}

Kaya, handa na ang aplikasyon. Ang mga web page ay maaaring humiling ng lagda para sa mga transaksyon:

Pagsusulat ng isang secure na extension ng browser

Pagsusulat ng isang secure na extension ng browser

Available ang code dito link.

Konklusyon

Kung nabasa mo na ang artikulo hanggang sa dulo, ngunit mayroon pa ring mga katanungan, maaari mong tanungin sila sa mga repositoryo na may extension. Doon ay makikita mo rin ang mga commit para sa bawat itinalagang hakbang.

At kung interesado kang tingnan ang code para sa aktwal na extension, mahahanap mo ito dito.

Code, repositoryo at paglalarawan ng trabaho mula sa siemarell

Pinagmulan: www.habr.com

Magdagdag ng komento