Shkrimi i një shtesë të sigurt të shfletuesit

Shkrimi i një shtesë të sigurt të shfletuesit

Ndryshe nga arkitektura e zakonshme "klient-server", aplikacionet e decentralizuara karakterizohen nga:

  • Nuk ka nevojë të ruani një bazë të dhënash me hyrjet dhe fjalëkalimet e përdoruesve. Informacioni i aksesit ruhet ekskluzivisht nga vetë përdoruesit dhe konfirmimi i autenticitetit të tyre ndodh në nivel protokolli.
  • Nuk ka nevojë të përdorni një server. Logjika e aplikacionit mund të ekzekutohet në një rrjet blockchain, ku është e mundur të ruhet sasia e kërkuar e të dhënave.

Ekzistojnë 2 depo relativisht të sigurta për çelësat e përdoruesve - kuletat e harduerit dhe shtesat e shfletuesit. Kuletat e harduerit janë kryesisht jashtëzakonisht të sigurta, por të vështira për t'u përdorur dhe larg të qenit të lira, por shtesat e shfletuesit janë kombinimi i përsosur i sigurisë dhe lehtësisë së përdorimit, dhe gjithashtu mund të jenë plotësisht falas për përdoruesit fundorë.

Duke marrë parasysh të gjitha këto, ne donim të bënim zgjerimin më të sigurt që thjeshton zhvillimin e aplikacioneve të decentralizuara duke ofruar një API të thjeshtë për të punuar me transaksionet dhe nënshkrimet.
Për këtë përvojë do t'ju tregojmë më poshtë.

Artikulli do të përmbajë udhëzime hap pas hapi se si të shkruani një shtesë të shfletuesit, me shembuj kodesh dhe pamje nga ekrani. Ju mund të gjeni të gjithë kodin në depove. Çdo kryerje korrespondon logjikisht me një seksion të këtij neni.

Një histori e shkurtër e shtesave të shfletuesit

Zgjerimet e shfletuesit kanë ekzistuar për një kohë të gjatë. Ata u shfaqën në Internet Explorer në vitin 1999, në Firefox në 2004. Sidoqoftë, për një kohë shumë të gjatë nuk kishte asnjë standard të vetëm për zgjerimet.

Mund të themi se u shfaq së bashku me shtesat në versionin e katërt të Google Chrome. Sigurisht, atëherë nuk kishte asnjë specifikim, por ishte API i Chrome që u bë baza e tij: pasi kishte pushtuar pjesën më të madhe të tregut të shfletuesit dhe duke pasur një dyqan aplikacionesh të integruar, Chrome në fakt vendosi standardin për shtesat e shfletuesit.

Mozilla kishte standardin e vet, por duke parë popullaritetin e shtesave të Chrome, kompania vendosi të bënte një API të pajtueshme. Në vitin 2015, me iniciativën e Mozilla-s, u krijua një grup i posaçëm në kuadër të Konsorciumit World Wide Web (W3C) për të punuar në specifikimet e zgjerimit të ndër-shfletuesve.

Shtesat ekzistuese API për Chrome janë marrë si bazë. Puna u krye me mbështetjen e Microsoft (Google refuzoi të merrte pjesë në zhvillimin e standardit), dhe si rezultat u shfaq një draft specifikimet.

Formalisht, specifikimi mbështetet nga Edge, Firefox dhe Opera (vini re se Chrome nuk është në këtë listë). Por në fakt, standardi është kryesisht i pajtueshëm me Chrome, pasi në fakt është shkruar bazuar në shtesat e tij. Mund të lexoni më shumë rreth WebExtensions API këtu.

Struktura e zgjatjes

Skedari i vetëm që kërkohet për shtesën është manifesti (manifest.json). Është gjithashtu "pika hyrëse" në zgjerim.

manifest

Sipas specifikimeve, skedari i manifestit është një skedar i vlefshëm JSON. Një përshkrim i plotë i çelësave të manifestit me informacion se cilët çelësa mbështeten në cilin shfletues mund të shikohet këtu.

Çelësat që nuk janë në specifikimin "mund" të shpërfillen (si Chrome ashtu edhe Firefox raportojnë gabime, por shtesat vazhdojnë të funksionojnë).

Dhe unë do të doja të tërhiqja vëmendjen në disa pika.

  1. sfond - një objekt që përfshin fushat e mëposhtme:
    1. scripts — një grup skriptesh që do të ekzekutohen në kontekstin e sfondit (ne do të flasim për këtë pak më vonë);
    2. faqe - në vend të skripteve që do të ekzekutohen në një faqe të zbrazët, mund të specifikoni html me përmbajtje. Në këtë rast, fusha e skriptit do të shpërfillet dhe skriptet do të duhet të futen në faqen e përmbajtjes;
    3. vazhdon — një flamur binar, nëse nuk specifikohet, shfletuesi do të "vrasë" procesin e sfondit kur mendon se nuk po bën asgjë dhe do ta rifillojë nëse është e nevojshme. Përndryshe, faqja do të shkarkohet vetëm kur shfletuesi është i mbyllur. Nuk mbështetet në Firefox.
  2. përmbajtja_skriptet — një grup objektesh që ju lejon të ngarkoni skripta të ndryshëm në faqe të ndryshme ueb. Çdo objekt përmban fushat e mëposhtme të rëndësishme:
    1. ndeshjet - url-ja e modelit, i cili përcakton nëse një skrip i caktuar i përmbajtjes do të përfshihet apo jo.
    2. js — një listë skriptesh që do të ngarkohen në këtë ndeshje;
    3. përjashtoj_ndeshjet - përjashton nga fusha match URL-të që përputhen me këtë fushë.
  3. faqe_veprim - është në fakt një objekt që është përgjegjës për ikonën që shfaqet pranë shiritit të adresave në shfletues dhe ndërveprimin me të. Gjithashtu ju lejon të shfaqni një dritare kërcyese, e cila përcaktohet duke përdorur HTML, CSS dhe JS tuaj.
    1. default_popup - rruga për në skedarin HTML me ndërfaqen kërcyese, mund të përmbajë CSS dhe JS.
  4. lejet — një grup për menaxhimin e të drejtave të zgjerimit. Ekzistojnë 3 lloje të të drejtave, të cilat janë përshkruar në detaje këtu
  5. burimet_e_aksesueshme në internet — burimet shtesë që mund të kërkojë një faqe ueb, për shembull, imazhe, skedarë JS, CSS, HTML.
  6. e jashtme_e lidhur — këtu mund të specifikoni në mënyrë eksplicite ID-të e shtesave dhe domeneve të tjera të faqeve të internetit nga të cilat mund të lidheni. Një domen mund të jetë i nivelit të dytë ose më i lartë. Nuk funksionon në Firefox.

Konteksti i ekzekutimit

Shtesa ka tre kontekste të ekzekutimit të kodit, domethënë aplikacioni përbëhet nga tre pjesë me nivele të ndryshme aksesi në API të shfletuesit.

Konteksti i zgjerimit

Shumica e API-së është në dispozicion këtu. Në këtë kontekst ata "jetojnë":

  1. Faqja e sfondit — pjesa “backend” e ekstensionit. Skedari specifikohet në manifest duke përdorur tastin "background".
  2. Faqja kërcyese — një faqe kërcyese që shfaqet kur klikoni në ikonën shtesë. Në manifest browser_action -> default_popup.
  3. Faqe e personalizuar — faqja e zgjerimit, "living" në një skedë të veçantë të pamjes chrome-extension://<id_расширения>/customPage.html.

Ky kontekst ekziston në mënyrë të pavarur nga dritaret dhe skedat e shfletuesit. Faqja e sfondit ekziston në një kopje të vetme dhe funksionon gjithmonë (përjashtim është faqja e ngjarjes, kur skenari i sfondit niset nga një ngjarje dhe "vdes" pas ekzekutimit të tij). Faqja kërcyese ekziston kur dritarja kërcyese është e hapur, dhe Faqe e personalizuar — ndërsa skeda me të është e hapur. Nuk ka qasje në skedat e tjera dhe përmbajtjet e tyre nga ky kontekst.

Konteksti i skriptit të përmbajtjes

Skedari i skriptit të përmbajtjes hapet së bashku me secilën skedë të shfletuesit. Ka akses në një pjesë të API-së së shtesës dhe në pemën DOM të faqes së internetit. Janë skriptet e përmbajtjes që janë përgjegjës për ndërveprimin me faqen. Shtesat që manipulojnë pemën DOM e bëjnë këtë në skriptet e përmbajtjes - për shembull, bllokuesit e reklamave ose përkthyesit. Gjithashtu, skripti i përmbajtjes mund të komunikojë me faqen përmes standardit postMessage.

Konteksti i faqes në internet

Kjo është vetë faqja aktuale e internetit. Nuk ka të bëjë me zgjerimin dhe nuk ka qasje atje, përveç rasteve kur domeni i kësaj faqeje nuk tregohet në mënyrë eksplicite në manifest (më shumë për këtë më poshtë).

mesazheve

Pjesë të ndryshme të aplikacionit duhet të shkëmbejnë mesazhe me njëra-tjetrën. Ekziston një API për këtë runtime.sendMessage për të dërguar një mesazh background и tabs.sendMessage për të dërguar një mesazh në një faqe (skript i përmbajtjes, dritare kërcyese ose faqe në internet nëse është e disponueshme externally_connectable). Më poshtë është një shembull kur qaseni në API të Chrome.

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

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

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

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

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

Për komunikim të plotë, mund të krijoni lidhje përmes runtime.connect. Në përgjigje do të marrim runtime.Port, tek e cila, ndërsa është e hapur, mund të dërgoni çdo numër mesazhesh. Në anën e klientit, për shembull, contentscript, duket kështu:

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

Serveri ose sfondi:

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

Ekziston edhe një ngjarje onDisconnect dhe metodë disconnect.

Diagrami i aplikimit

Le të bëjmë një shtesë të shfletuesit që ruan çelësat privatë, siguron akses në informacionin publik (adresa, çelësi publik komunikon me faqen dhe lejon aplikacionet e palëve të treta të kërkojnë një nënshkrim për transaksione.

Zhvillimi i aplikacionit

Aplikacioni ynë duhet të ndërveprojë me përdoruesin dhe të sigurojë faqen me një API për të thirrur metoda (për shembull, për të nënshkruar transaksione). Mjaftoni vetëm me një contentscript nuk do të funksionojë, pasi ka akses vetëm në DOM, por jo në JS të faqes. Lidhu nëpërmjet runtime.connect ne nuk mundemi, sepse API është i nevojshëm në të gjitha domenet, dhe vetëm ato specifike mund të specifikohen në manifest. Si rezultat, diagrami do të duket si ky:

Shkrimi i një shtesë të sigurt të shfletuesit

Do të ketë një skenar tjetër - inpage, të cilin do ta injektojmë në faqe. Ai do të funksionojë në kontekstin e tij dhe do të sigurojë një API për të punuar me shtesën.

Fillim

I gjithë kodi shtesë i shfletuesit është i disponueshëm në GitHub. Gjatë përshkrimit do të ketë lidhje me angazhimet.

Le të fillojmë me manifestin:

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

Krijo background.js bosh, popup.js, inpage.js dhe contentscript.js. Ne shtojmë popup.html - dhe aplikacioni ynë tashmë mund të ngarkohet në Google Chrome dhe të sigurohemi që funksionon.

Për ta verifikuar këtë, mund të merrni kodin prandaj. Përveç asaj që bëmë, lidhja konfiguroi montimin e projektit duke përdorur uebpack. Për të shtuar një aplikacion në shfletues, në chrome://extensions duhet të zgjidhni load unpacked dhe dosjen me shtesën përkatëse - në rastin tonë dist.

Shkrimi i një shtesë të sigurt të shfletuesit

Tani zgjerimi ynë është instaluar dhe funksionon. Ju mund të ekzekutoni veglat e zhvilluesit për kontekste të ndryshme si më poshtë:

popup ->

Shkrimi i një shtesë të sigurt të shfletuesit

Qasja në tastierën e skriptit të përmbajtjes kryhet përmes tastierës së vetë faqes në të cilën është nisur.Shkrimi i një shtesë të sigurt të shfletuesit

mesazheve

Pra, ne duhet të krijojmë dy kanale komunikimi: sfondi i faqes <-> dhe sfondi i dritares <->. Ju, sigurisht, mund të dërgoni mesazhe në port dhe të shpikni protokollin tuaj, por unë preferoj qasjen që pashë në projektin me burim të hapur metamask.

Ky është një shtesë e shfletuesit për të punuar me rrjetin Ethereum. Në të, pjesë të ndryshme të aplikacionit komunikojnë përmes RPC duke përdorur bibliotekën dnode. Kjo ju lejon të organizoni një shkëmbim mjaft shpejt dhe me lehtësi nëse i siguroni një rrymë nodejs si një transport (që do të thotë një objekt që zbaton të njëjtën ndërfaqe):

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

Tani do të krijojmë një klasë aplikacioni. Do të krijojë objekte API për dritaren kërcyese dhe faqen e internetit dhe do të krijojë një dnode për to:

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

Këtu dhe më poshtë, në vend të objektit global Chrome, ne përdorim extensionApi, i cili akseson Chrome në shfletuesin e Google dhe shfletuesin në të tjerët. Kjo është bërë për pajtueshmërinë e ndër-shfletuesve, por për qëllimet e këtij artikulli mund të përdoret thjesht 'chrome.runtime.connect'.

Le të krijojmë një shembull aplikacioni në skriptin e sfondit:

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

Meqenëse dnode punon me rryma, dhe ne marrim një port, nevojitet një klasë përshtatës. Është bërë duke përdorur bibliotekën e rrjedhës së lexueshme, e cila zbaton rrjedhat nodejs në shfletues:

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

Tani le të krijojmë një lidhje në 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;
    }
}

Pastaj krijojmë lidhjen në skriptin e përmbajtjes:

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

Meqenëse ne kemi nevojë për API jo në skriptin e përmbajtjes, por direkt në faqe, ne bëjmë dy gjëra:

  1. Ne krijojmë dy rrjedha. Një - drejt faqes, në krye të postMesazhit. Për këtë ne përdorim këtë këtë paketë nga krijuesit e metamaskës. Rrjedha e dytë është në sfond mbi portin e marrë nga runtime.connect. Le t'i blejmë ato. Tani faqja do të ketë një transmetim në sfond.
  2. Injektoni skriptin në DOM. Shkarkoni skriptin (qasja në të ishte e lejuar në manifest) dhe krijoni një etiketë script me përmbajtjen e tij brenda:

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

Tani krijojmë një objekt api në faqe dhe e vendosim atë në 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;
}

Ne jemi gati Thirrje me procedurë në distancë (RPC) me API të veçantë për faqen dhe ndërfaqen e përdoruesit. Kur lidhim një faqe të re me sfondin, mund të shohim këtë:

Shkrimi i një shtesë të sigurt të shfletuesit

API bosh dhe origjina. Në anën e faqes, ne mund ta quajmë funksionin hello si kjo:

Shkrimi i një shtesë të sigurt të shfletuesit

Puna me funksionet e kthimit të thirrjes në JS moderne është sjellje e keqe, kështu që le të shkruajmë një ndihmës të vogël për të krijuar një dnode që ju lejon të kaloni një objekt API tek utils.

Objektet API tani do të duken kështu:

export class SignerApp {

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

...

}

Marrja e një objekti nga telekomanda si kjo:

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

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

Dhe thirrja e funksioneve kthen një premtim:

Shkrimi i një shtesë të sigurt të shfletuesit

Versioni me funksione asinkrone në dispozicion këtu.

Në përgjithësi, qasja RPC dhe stream duket mjaft fleksibël: ne mund të përdorim multipleksimin me avull dhe të krijojmë disa API të ndryshme për detyra të ndryshme. Në parim, dnode mund të përdoret kudo, gjëja kryesore është të mbështillni transportin në formën e një rryme nodejs.

Një alternativë është formati JSON, i cili implementon protokollin JSON RPC 2. Megjithatë, funksionon me transporte specifike (TCP dhe HTTP(S)), që nuk është i zbatueshëm në rastin tonë.

Gjendja e brendshme dhe ruajtja lokale

Do të na duhet të ruajmë gjendjen e brendshme të aplikacionit - të paktën çelësat e nënshkrimit. Mund t'i shtojmë lehtësisht një gjendje aplikacionit dhe metodat për ta ndryshuar atë në API-në kërcyese:

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

    ...

} 

Në sfond, ne do të mbështjellim gjithçka në një funksion dhe do të shkruajmë objektin e aplikacionit në dritare në mënyrë që të mund të punojmë me të nga tastiera:

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

Le të shtojmë disa çelësa nga tastiera e UI dhe të shohim se çfarë ndodh me gjendjen:

Shkrimi i një shtesë të sigurt të shfletuesit

Gjendja duhet të bëhet këmbëngulëse në mënyrë që çelësat të mos humbasin gjatë rinisjes.

Ne do ta ruajmë atë në localStorage, duke e mbishkruar atë me çdo ndryshim. Më pas, qasja në të do të jetë gjithashtu e nevojshme për UI, dhe unë gjithashtu do të doja të abonoja ndryshimet. Bazuar në këtë, do të jetë e përshtatshme të krijoni një ruajtje të vëzhgueshme dhe të regjistroheni në ndryshimet e saj.

Ne do të përdorim bibliotekën mobx (https://github.com/mobxjs/mobx). Zgjedhja ra mbi të sepse nuk më duhej të punoja me të, por doja shumë ta studioja.

Le të shtojmë inicializimin e gjendjes fillestare dhe ta bëjmë dyqanin të vëzhgueshëm:

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

    ...

}

"Nën kapuç", mobx ka zëvendësuar të gjitha fushat e dyqanit me proxy dhe përgjon të gjitha thirrjet drejt tyre. Do të jetë e mundur të abonoheni në këto mesazhe.

Më poshtë do të përdor shpesh termin "kur ndryshohet", megjithëse kjo nuk është plotësisht e saktë. Mobx gjurmon qasjen në fusha. Përdoren marrës dhe vendosës të objekteve proxy që krijon biblioteka.

Dekoratorët e veprimit shërbejnë për dy qëllime:

  1. Në modalitetin e rreptë me flamurin EnforceActions, mobx ndalon ndryshimin e gjendjes drejtpërdrejt. Konsiderohet praktikë e mirë të punosh në kushte strikte.
  2. Edhe nëse një funksion ndryshon gjendjen disa herë - për shembull, ne ndryshojmë disa fusha në disa rreshta kodi - vëzhguesit njoftohen vetëm kur ai të përfundojë. Kjo është veçanërisht e rëndësishme për frontin, ku përditësimet e panevojshme të gjendjes çojnë në paraqitje të panevojshme të elementeve. Në rastin tonë, as e para dhe as e dyta nuk janë veçanërisht të rëndësishme, por ne do të ndjekim praktikat më të mira. Është e zakonshme të bashkëngjitni dekorues në të gjitha funksionet që ndryshojnë gjendjen e fushave të vëzhguara.

Në sfond do të shtojmë inicializimin dhe ruajtjen e gjendjes në 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)
        }
    }
}

Funksioni i reagimit është interesant këtu. Ai ka dy argumente:

  1. Përzgjedhësi i të dhënave.
  2. Një mbajtës që do të thirret me këto të dhëna sa herë që ndryshon.

Ndryshe nga redux, ku ne e marrim në mënyrë eksplicite gjendjen si argument, mobx kujton se cilat objekte të vëzhguara kemi akses brenda përzgjedhësit dhe thërret mbajtësin vetëm kur ato ndryshojnë.

Është e rëndësishme të kuptojmë saktësisht se si mobx vendos se në cilat objekte të vëzhguara abonojmë. Nëse do të shkruaja një përzgjedhës në kod si ky() => app.store, atëherë reagimi nuk do të quhet kurrë, pasi vetë ruajtja nuk është e vëzhgueshme, janë vetëm fushat e saj.

Nëse e kam shkruar kështu () => app.store.keys, atëherë përsëri asgjë nuk do të ndodhte, pasi me shtimin/heqjen e elementeve të grupit, referenca ndaj tij nuk do të ndryshojë.

Mobx vepron si përzgjedhës për herë të parë dhe mban gjurmët vetëm të vëzhguesve që ne kemi akses. Kjo bëhet përmes përfituesve proxy. Prandaj, funksioni i integruar përdoret këtu toJS. Ai kthen një objekt të ri me të gjitha proxies të zëvendësuara me fushat origjinale. Gjatë ekzekutimit, ai lexon të gjitha fushat e objektit - kështu që marrësit aktivizohen.

Në tastierën kërcyese do të shtojmë përsëri disa çelësa. Këtë herë ata gjithashtu përfunduan në localStorage:

Shkrimi i një shtesë të sigurt të shfletuesit

Kur faqja e sfondit ngarkohet përsëri, informacioni mbetet në vend.

I gjithë kodi i aplikacionit deri në këtë pikë mund të shikohet këtu.

Ruajtja e sigurt e çelësave privatë

Ruajtja e çelësave privatë në tekst të qartë është e pasigurt: ekziston gjithmonë mundësia që të hakeroheni, të keni akses në kompjuterin tuaj, etj. Prandaj, në localStorage ne do t'i ruajmë çelësat në një formë të koduar me fjalëkalim.

Për siguri më të madhe, ne do të shtojmë një gjendje të kyçur në aplikacion, në të cilën nuk do të ketë fare akses te çelësat. Ne do ta transferojmë automatikisht shtesën në gjendjen e kyçur për shkak të një afati kohor.

Mobx ju lejon të ruani vetëm një grup minimal të dhënash, dhe pjesa tjetër llogaritet automatikisht në bazë të tij. Këto janë të ashtuquajturat veti të llogaritura. Ato mund të krahasohen me pamjet në bazat e të dhënave:

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

Tani ruajmë vetëm çelësat dhe fjalëkalimin e koduar. Gjithçka tjetër është e llogaritur. Ne e bëjmë transferimin në një gjendje të bllokuar duke hequr fjalëkalimin nga gjendja. API publike tani ka një metodë për inicializimin e ruajtjes.

Shkruar për enkriptim shërbimet që përdorin 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)
}

Shfletuesi ka një API joaktive përmes së cilës mund të abonoheni në një ngjarje - ndryshimet e gjendjes. Shteti, në përputhje me rrethanat, mund të jetë idle, active и locked. Për momentin në punë mund të caktoni një afat kohor dhe "kyçja" vendoset kur vetë sistemi operativ është i bllokuar. Ne gjithashtu do të ndryshojmë përzgjedhësin për ruajtje në 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)
        }
    }
}

Kodi para këtij hapi është këtu.

transaksionet

Pra, kemi ardhur te gjëja më e rëndësishme: krijimi dhe nënshkrimi i transaksioneve në blockchain. Ne do të përdorim zinxhirin dhe bibliotekën WAVES valë-transaksione.

Së pari, le t'i shtojmë gjendjes një sërë mesazhesh që duhet të nënshkruhen, më pas shtojmë metoda për shtimin e një mesazhi të ri, konfirmimin e nënshkrimit dhe refuzimin:

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

    ...
}

Kur marrim një mesazh të ri, ne i shtojmë meta të dhëna, bëjeni observable dhe shtoni në store.messages.

Nëse nuk e bëni observable manualisht, atëherë mobx do ta bëjë vetë kur shton mesazhe në grup. Megjithatë, do të krijojë një objekt të ri për të cilin nuk do të kemi një referencë, por do të na duhet për hapin tjetër.

Më pas, ne kthejmë një premtim që zgjidhet kur ndryshon statusi i mesazhit. Statusi monitorohet nga reagimi, i cili do të "vrasë veten" kur statusi të ndryshojë.

Kodi i metodës approve и reject shumë e thjeshtë: thjesht ndryshojmë statusin e mesazhit, pasi ta nënshkruajmë nëse është e nevojshme.

Ne vendosim Aprovimin dhe refuzimin në API të ndërfaqes së përdoruesit, mesazhin e ri në API të faqes:

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

    ...
}

Tani le të përpiqemi të nënshkruajmë transaksionin me shtesën:

Shkrimi i një shtesë të sigurt të shfletuesit

Në përgjithësi, gjithçka është gati, gjithçka që mbetet është shtoni UI të thjeshtë.

UI

Ndërfaqja ka nevojë për qasje në gjendjen e aplikacionit. Nga ana UI do të bëjmë observable gjendje dhe shtoni një funksion në API që do ta ndryshojë këtë gjendje. Le të shtojmë observable te objekti API i marrë nga sfondi:

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

Në fund fillojmë të japim ndërfaqen e aplikacionit. Ky është një aplikacion react. Objekti i sfondit thjesht kalohet duke përdorur mbështetëse. Do të ishte e saktë, natyrisht, të bëhej një shërbim i veçantë për metodat dhe një dyqan për shtetin, por për qëllimet e këtij neni mjafton:

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

Me mobx është shumë e lehtë të filloni të jepni kur të dhënat ndryshojnë. Ne thjesht e varim dekoruesin vëzhgues nga paketa mobx-reagoj në komponent, dhe renderi do të thirret automatikisht kur çdo gjë e vëzhgueshme e referuar nga komponenti ndryshon. Ju nuk keni nevojë për ndonjë mapStateToProps ose lidheni si në redux. Gjithçka funksionon menjëherë nga kutia:

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

Komponentët e mbetur mund të shihen në kod në dosjen UI.

Tani në klasën e aplikacionit ju duhet të bëni një përzgjedhës të gjendjes për UI dhe të njoftoni UI kur ajo të ndryshojë. Për ta bërë këtë, le të shtojmë një metodë getState и reactionduke thirrur 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())

        })
    }

    ...
}

Gjatë marrjes së një objekti remote është krijuar reaction për të ndryshuar gjendjen që thërret funksionin në anën e ndërfaqes së përdoruesit.

Prekja e fundit është të shtoni shfaqjen e mesazheve të reja në ikonën shtesë:

function setupApp() {
...

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

...
}

Pra, aplikacioni është gati. Faqet e internetit mund të kërkojnë një nënshkrim për transaksionet:

Shkrimi i një shtesë të sigurt të shfletuesit

Shkrimi i një shtesë të sigurt të shfletuesit

Kodi është i disponueshëm këtu lidhje.

Përfundim

Nëse e keni lexuar artikullin deri në fund, por keni ende pyetje, mund t'i bëni ato në depo me shtrirje. Aty do të gjeni gjithashtu angazhime për çdo hap të caktuar.

Dhe nëse jeni të interesuar të shikoni kodin për zgjerimin aktual, mund ta gjeni këtë këtu.

Kodi, depoja dhe përshkrimi i punës nga siemarell

Burimi: www.habr.com

Shto një koment