Rubutun tsawaita mai tsaro

Rubutun tsawaita mai tsaro

Ba kamar tsarin gine-gine na “abokin ciniki-uwar garke” na gama gari ba, aikace-aikacen da ba a san su ba suna halin su:

  • Babu buƙatar adana bayanan bayanai tare da shiga mai amfani da kalmomin shiga. Ana adana bayanan isa ga masu amfani da kansu kawai, kuma tabbatar da sahihancinsu yana faruwa a matakin yarjejeniya.
  • Babu buƙatar amfani da uwar garken. Ana iya aiwatar da dabaru na aikace-aikacen akan hanyar sadarwar blockchain, inda zai yiwu a adana adadin da ake buƙata.

Akwai ma'ajiyar aminci guda 2 don maɓallan mai amfani - walat ɗin kayan aiki da kari na burauza. Wallet ɗin kayan masarufi galibi suna da aminci sosai, amma suna da wahalar amfani kuma suna da nisa daga kyauta, amma kari na bincike shine cikakken haɗin tsaro da sauƙin amfani, kuma yana iya zama cikakkiyar kyauta ga masu amfani da ƙarshe.

Yin la'akari da wannan duka, muna son yin tsawaita mafi aminci wanda ke sauƙaƙe haɓaka aikace-aikacen da ba a daidaita su ta hanyar samar da API mai sauƙi don aiki tare da ma'amaloli da sa hannu.
Za mu gaya muku game da wannan kwarewa a kasa.

Labarin zai ƙunshi umarnin mataki-mataki kan yadda ake rubuta tsawo na mashigar bincike, tare da misalan lamba da hotunan kariyar kwamfuta. Kuna iya samun duk lambar a ciki wuraren ajiya. Kowane aikatawa a hankali ya yi daidai da sashe na wannan labarin.

Takaitaccen Tarihin Tsare-Tsare Na Ma'aunin Bincike

An daɗe da daɗe da kari na Browser. Sun bayyana a cikin Internet Explorer baya a cikin 1999, a Firefox a cikin 2004. Duk da haka, na dogon lokaci babu ma'auni ɗaya don kari.

Za mu iya cewa ya bayyana tare da kari a cikin na huɗu na Google Chrome. Tabbas, babu wani takamaiman bayani a lokacin, amma Chrome API ne ya zama tushensa: kasancewar ya ci yawancin kasuwannin mai binciken kuma yana da ginannen kantin sayar da aikace-aikacen, Chrome a zahiri ya kafa ma'auni don haɓaka mai bincike.

Mozilla tana da nata ma'auni, amma ganin shaharar kari na Chrome, kamfanin ya yanke shawarar yin API mai jituwa. A cikin 2015, a yunƙurin Mozilla, an ƙirƙiri wata ƙungiya ta musamman a cikin Ƙungiyar Yanar Gizon Yanar Gizo ta Duniya (W3C) don yin aiki akan ƙayyadaddun ƙayyadaddun fa'idodin browsing.

Abubuwan kari na API na Chrome an ɗauki su azaman tushe. An gudanar da aikin tare da goyon bayan Microsoft (Google ya ƙi shiga cikin ci gaban ma'auni), kuma a sakamakon haka wani daftarin aiki ya bayyana. bayani dalla-dalla.

A bisa ƙa'ida, ƙayyadaddun yana da goyan bayan Edge, Firefox da Opera (lura cewa Chrome baya cikin wannan jerin). Amma a zahiri, ma'aunin ya fi dacewa da Chrome, tunda a zahiri an rubuta shi bisa kari. Kuna iya karanta ƙarin game da WebExtensions API a nan.

Tsarin haɓakawa

Fayil ɗaya kawai da ake buƙata don haɓakawa shine bayyanannen (manifest.json). Hakanan shine "maganin shigarwa" zuwa fadadawa.

Bayyana

Dangane da ƙayyadaddun bayanai, fayil ɗin bayyanuwa ingantaccen fayil ne na JSON. Cikakken bayanin maɓallan bayyanuwa tare da bayani game da waɗanne maɓallan da ake tallafawa waɗanda za a iya duba mai binciken a nan.

Maɓallan da ba a cikin ƙayyadaddun bayanai ba za a iya watsi da su (duka Chrome da Firefox sun ba da rahoton kurakurai, amma kari na ci gaba da aiki).

Kuma ina so in jawo hankali ga wasu batutuwa.

  1. baya - wani abu wanda ya hada da fa'idodi masu zuwa:
    1. rubutun - tsararrun rubutun da za a aiwatar a cikin mahallin baya (zamu yi magana game da wannan kadan daga baya);
    2. Page - maimakon rubutun da za a aiwatar a cikin wani shafi mara komai, zaku iya saka html tare da abun ciki. A wannan yanayin, za a yi watsi da filin rubutun, kuma ana buƙatar shigar da rubutun a cikin shafin abun ciki;
    3. dage - Tutar binary, idan ba a ƙayyade ba, mai binciken zai "kashe" tsarin baya lokacin da ya ɗauki cewa ba ya yin wani abu, kuma zai sake farawa idan ya cancanta. In ba haka ba, za a sauke shafin ne kawai lokacin da mai lilo ya rufe. Ba a tallafawa a Firefox.
  2. rubutun abun ciki - tsararrun abubuwa waɗanda ke ba ku damar loda rubutun daban-daban zuwa shafukan yanar gizo daban-daban. Kowane abu ya ƙunshi fage masu mahimmanci masu zuwa:
    1. ashana - tsarin url, wanda ke ƙayyade ko za a haɗa wani rubutun abun ciki ko a'a.
    2. js - jerin rubutun da za a loda cikin wannan wasa;
    3. ware_matches - ware daga filin match URLs da suka dace da wannan filin.
  3. shafi_aiki - hakika wani abu ne wanda ke da alhakin alamar da aka nuna kusa da adireshin adireshin a cikin mashigin da kuma hulɗa da shi. Hakanan yana ba ku damar nuna taga popup, wanda aka ayyana ta amfani da HTML, CSS da JS naku.
    1. default_popup - hanyar zuwa fayil ɗin HTML tare da fafutukar buɗe ido, na iya ƙunsar CSS da JS.
  4. izini - tsararru don sarrafa haƙƙin haɓakawa. Akwai nau'ikan haƙƙi guda uku, waɗanda aka bayyana su dalla-dalla a nan
  5. web_accessible_resources - ƙarin albarkatun da shafin yanar gizon zai iya nema, misali, hotuna, JS, CSS, fayilolin HTML.
  6. waje_mai haɗawa - Anan zaku iya bayyana ID na wasu kari da wuraren shafukan yanar gizon da zaku iya haɗawa. Yankin na iya zama matakin na biyu ko mafi girma. Ba ya aiki a Firefox.

Yanayin aiwatarwa

Tsawaita yana da mahallin aiwatar da code guda uku, wato, aikace-aikacen ya ƙunshi sassa uku tare da matakan samun dama ga API ɗin mai binciken.

Yanayin haɓakawa

Yawancin API ɗin yana nan. A cikin wannan mahallin suna "rayuwa":

  1. Shafin bangon baya - sashin "baya" na tsawo. An ƙayyadadden fayil ɗin a cikin bayyanuwa ta amfani da maɓallin “baya”.
  2. Shafin Popup - shafi mai tasowa wanda ke bayyana lokacin da ka danna gunkin tsawo. A cikin bayanin browser_action -> default_popup.
  3. Shafin na al'ada - shafi mai tsawo, "rayuwa" a cikin wani shafin daban na kallo chrome-extension://<id_расширения>/customPage.html.

Wannan mahallin ya wanzu ba tare da windows da shafuka masu bincike ba. Shafin bangon baya yana kasancewa a cikin kwafi ɗaya kuma koyaushe yana aiki (banda shine shafin taron, lokacin da aka ƙaddamar da rubutun baya ta wani taron kuma “ya mutu” bayan aiwatar da shi). Shafin Popup akwai lokacin da taga popup ya buɗe, kuma Shafin na al'ada - yayin da shafin tare da shi a bude yake. Babu damar shiga wasu shafuka da abubuwan da ke cikin su daga wannan mahallin.

mahallin rubutun abun ciki

An ƙaddamar da fayil ɗin rubutun abun ciki tare da kowane shafin mai bincike. Yana da damar zuwa wani ɓangaren API ɗin tsawaita da zuwa bishiyar DOM na shafin yanar gizon. Rubutun abun ciki ne ke da alhakin hulɗa tare da shafin. Extensions waɗanda ke sarrafa itacen DOM suna yin wannan a cikin rubutun abun ciki - misali, masu hana talla ko masu fassara. Hakanan, rubutun abun ciki na iya sadarwa tare da shafin ta hanyar daidaitaccen tsari postMessage.

mahallin shafin yanar gizon

Wannan ita ce ainihin shafin yanar gizon kanta. Ba shi da alaƙa da tsawo kuma ba shi da damar zuwa can, sai dai a lokuta inda ba a bayyana yankin wannan shafin a fili a cikin bayanin (ƙari akan wannan a ƙasa).

Musayar saƙo

Dole ne sassa daban-daban na aikace-aikacen su yi musayar saƙonni da juna. Akwai API don wannan runtime.sendMessage don aika sako background и tabs.sendMessage don aika sako zuwa shafi (rubutun abun ciki, popup ko shafin yanar gizo idan akwai externally_connectable). A ƙasa akwai misali lokacin samun dama ga 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))
    }
)

Don cikakkiyar sadarwa, zaku iya ƙirƙirar haɗi ta hanyar runtime.connect. A mayar da martani za mu samu runtime.Port, wanda, yayin da yake buɗewa, zaku iya aika kowane adadin saƙonni. A gefen abokin ciniki, alal misali, contentscript, kamar haka:

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

Sabar ko bango:

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

Akwai kuma wani taron onDisconnect da hanya disconnect.

Tsarin aikace-aikacen

Bari mu yi tsawo na burauza wanda ke adana maɓallai masu zaman kansu, yana ba da damar samun bayanan jama'a (adireshi, maɓallin jama'a yana sadarwa tare da shafi kuma yana ba da damar aikace-aikacen ɓangare na uku don neman sa hannu don ma'amala.

Ci gaban aikace-aikacen

Dole ne aikace-aikacen mu su yi hulɗa tare da mai amfani kuma su samar da shafin tare da API don kiran hanyoyin (misali, don sanya hannu kan ma'amaloli). Yi da guda ɗaya kawai contentscript ba zai yi aiki ba, tunda yana da damar zuwa DOM kawai, amma ba zuwa JS na shafin ba. Haɗa ta hanyar runtime.connect ba za mu iya ba, saboda ana buƙatar API akan duk yankuna, kuma takamaiman kawai za'a iya ƙayyade a cikin bayyanar. A sakamakon haka, zane zai kasance kamar haka:

Rubutun tsawaita mai tsaro

Za a sami wani rubutun - inpage, wanda za mu yi allura a cikin shafin. Zai gudana a cikin mahallin sa kuma ya samar da API don aiki tare da tsawo.

Начало

Ana samun duk lambar tsawo na burauza a GitHub. A lokacin bayanin za a sami hanyoyin haɗin kai don aikatawa.

Bari mu fara da ma'anar:

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

Ƙirƙiri fanko bango.js, popup.js, inpage.js da contentscript.js. Muna ƙara popup.html - kuma an riga an loda aikace-aikacen mu cikin Google Chrome kuma a tabbata yana aiki.

Don tabbatar da wannan, zaku iya ɗaukar lambar daga nan. Bugu da ƙari ga abin da muka yi, hanyar haɗin yanar gizon ta daidaita taron aikin ta amfani da fakitin yanar gizo. Don ƙara aikace-aikacen zuwa mai bincike, a cikin chrome: // kari kuna buƙatar zaɓar kayan da ba a tattara ba da babban fayil ɗin da ke da tsawo daidai - a cikin yanayinmu dist.

Rubutun tsawaita mai tsaro

Yanzu an shigar da tsawo kuma yana aiki. Kuna iya gudanar da kayan aikin haɓaka don mahallin daban-daban kamar haka:

popup ->

Rubutun tsawaita mai tsaro

Ana yin amfani da na'ura mai kwakwalwa ta rubutun abun ciki ta hanyar na'ura mai kwakwalwa na shafin da kansa wanda aka kaddamar da shi.Rubutun tsawaita mai tsaro

Musayar saƙo

Don haka, muna buƙatar kafa hanyoyin sadarwa guda biyu: inpage <-> bangon baya da bugu <-> bangon. Kuna iya, ba shakka, kawai aika saƙonni zuwa tashar jiragen ruwa da ƙirƙira naku yarjejeniya, amma na fi son tsarin da na gani a cikin aikin buɗe tushen metamask.

Wannan ƙari ne mai bincike don aiki tare da cibiyar sadarwar Ethereum. A ciki, sassa daban-daban na aikace-aikacen suna sadarwa ta hanyar RPC ta amfani da ɗakin karatu na dnode. Yana ba ku damar tsara musanya cikin sauri da dacewa idan kun samar da shi tare da rafin nodejs azaman jigilar kaya (ma'ana wani abu da ke aiwatar da ƙirar iri ɗaya):

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

Yanzu za mu ƙirƙiri ajin aikace-aikace. Zai ƙirƙiri abubuwan API don buguwa da shafin yanar gizon, kuma ya ƙirƙira musu dnode:

import Dnode from 'dnode/browser';

export class SignerApp {

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

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

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

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

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

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

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

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

Anan da ƙasa, maimakon abin Chrome na duniya, muna amfani da extensionApi, wanda ke shiga Chrome a cikin burauzar Google da browser a cikin wasu. Ana yin wannan ne don daidaitawar mai bincike, amma don dalilan wannan labarin, ana iya amfani da 'chrome.runtime.connect' kawai.

Bari mu ƙirƙiri misalin aikace-aikace a rubutun baya:

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

Tun da dnode yana aiki tare da rafuka, kuma muna karɓar tashar jiragen ruwa, ana buƙatar ajin adaftar. An yi shi ta amfani da ɗakin karatu mai karantawa-rafi, wanda ke aiwatar da rafukan nodejs a cikin mai binciken:

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

Yanzu bari mu ƙirƙiri haɗi a cikin 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;
    }
}

Sannan mu ƙirƙiri haɗin kai a cikin rubutun abun ciki:

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

Tun da muna buƙatar API ba a cikin rubutun abun ciki ba, amma kai tsaye a kan shafin, muna yin abubuwa biyu:

  1. Mun ƙirƙira koguna biyu. Daya - zuwa shafin, a saman sakon sakon. Don wannan muna amfani da wannan wannan kunshin daga masu yin metamask. Rafi na biyu shine zuwa bangon tashar jiragen ruwa da aka karɓa daga gare ta runtime.connect. Mu saya su. Yanzu shafin zai sami rafi zuwa bango.
  2. Allurar da rubutun a cikin DOM. Zazzage rubutun (an ba da izinin samun damar yin amfani da shi a cikin bayanan) kuma ƙirƙirar alama script da abinda ke ciki:

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

Yanzu mun ƙirƙiri wani abu api a cikin shafi kuma saita shi zuwa duniya:

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

Mun shirya Kiran Hanyar Nesa (RPC) tare da API daban don shafi da UI. Lokacin haɗa sabon shafi zuwa bango zamu iya ganin wannan:

Rubutun tsawaita mai tsaro

API mara komai da asali. A gefen shafi, zamu iya kiran aikin hello kamar haka:

Rubutun tsawaita mai tsaro

Yin aiki tare da ayyukan sake kira a cikin JS na zamani shine mummunar ɗabi'a, don haka bari mu rubuta ƙaramin mataimaki don ƙirƙirar dnode wanda ke ba ku damar wuce abin API zuwa abubuwan amfani.

Abubuwan API yanzu za su yi kama da haka:

export class SignerApp {

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

...

}

Samun abu daga nesa kamar haka:

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

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

Kuma ayyukan kira suna mayar da alƙawari:

Rubutun tsawaita mai tsaro

Sigar tare da ayyukan asynchronous akwai a nan.

Gabaɗaya, tsarin RPC da rafi yana da sauƙi: za mu iya amfani da yawan tururi da ƙirƙirar API daban-daban don ayyuka daban-daban. A ka'ida, za a iya amfani da dnode a ko'ina, babban abu shine kunsa sufuri a cikin hanyar nodejs rafi.

Wani madadin shine tsarin JSON, wanda ke aiwatar da ka'idar JSON RPC 2. Duk da haka, yana aiki tare da takamaiman jigilar kaya (TCP da HTTP (S)), waɗanda ba su da amfani a cikin yanayinmu.

Jiha na ciki da ma'ajiyar gida

Muna buƙatar adana yanayin ciki na aikace-aikacen - aƙalla maɓallan sa hannu. Za mu iya sauƙi ƙara yanayi zuwa aikace-aikacen da hanyoyin canza shi a cikin bututun 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)
        }
    }

    ...

} 

A bango, za mu nade komai a cikin aiki kuma mu rubuta abin aikace-aikacen zuwa taga domin mu yi aiki da shi daga na'ura mai kwakwalwa:

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

Bari mu ƙara ƴan maɓalli daga na'urar wasan bidiyo ta UI mu ga abin da ke faruwa da jihar:

Rubutun tsawaita mai tsaro

Ana buƙatar sanya jihar dagewa don kada maɓallan su ɓace lokacin sake farawa.

Za mu adana shi a cikin Storage na gida, mu sake rubuta shi tare da kowane canji. Daga baya, samun damar yin amfani da shi shima zai zama dole ga UI, kuma ina so in yi rajista don canje-canje. Dangane da wannan, zai zama dacewa don ƙirƙirar ajiya mai gani da biyan kuɗi ga canje-canjensa.

Za mu yi amfani da ɗakin karatu na mobx (https://github.com/mobxjs/mobx). Zaɓin ya faɗo a kai saboda ba dole ba ne in yi aiki da shi, amma ina so in yi nazarinsa sosai.

Bari mu ƙara ƙaddamar da yanayin farko kuma mu sanya kantin abin lura:

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

    ...

}

"Karƙashin kaho," mobx ya maye gurbin duk filayen shagunan tare da wakili kuma ya sata duk kira zuwa gare su. Zai yiwu a yi rajista ga waɗannan saƙonnin.

A ƙasa zan sau da yawa amfani da kalmar "lokacin canzawa", kodayake wannan ba daidai bane. Mobx yana bin hanyar shiga filayen. Ana amfani da maɓalli da saiti na abubuwan wakili waɗanda ɗakin karatu ke ƙirƙira.

Masu adon aiki suna amfani da dalilai guda biyu:

  1. A cikin tsauraran yanayi tare da tutar tilastawaActions, mobx ya hana canza jihar kai tsaye. Ana la'akari da kyakkyawan aiki don yin aiki a ƙarƙashin tsauraran yanayi.
  2. Ko da wani aiki ya canza jihar sau da yawa - alal misali, muna canza wurare da yawa a cikin layukan lamba da yawa - ana sanar da masu kallo kawai idan ya kammala. Wannan yana da mahimmanci musamman ga gaba, inda sabbin abubuwan da ba dole ba suna haifar da ma'anar abubuwan da ba dole ba. A cikin yanayinmu, ba na farko ko na biyu ba ya dace musamman, amma za mu bi mafi kyawun ayyuka. Yana da al'ada don haɗa masu ado zuwa duk ayyukan da ke canza yanayin filayen da aka lura.

A bango za mu ƙara farawa da adana jihar a cikin Ma'ajiyar gida:

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

Ayyukan amsawa yana da ban sha'awa a nan. Yana da dalilai guda biyu:

  1. Zabin bayanai.
  2. Mai sarrafa wanda za a kira shi da wannan bayanan duk lokacin da ya canza.

Ba kamar redux ba, inda muka karɓi jihar a sarari azaman gardama, mobx yana tuna waɗanne abubuwan lura muke shiga cikin mai zaɓin, kuma kawai ya kira mai kulawa lokacin da suka canza.

Yana da mahimmanci a fahimci ainihin yadda mobx ke yanke shawarar waɗanne abubuwan lura muke biyan kuɗi zuwa. Idan na rubuta mai zaɓe a lamba kamar haka() => app.store, to ba za a taɓa kiran martani ba, tunda ajiyar kanta ba a iya gani, filayensa kawai.

Idan na rubuta kamar haka () => app.store.keys, Sa'an nan kuma babu abin da zai faru, tun lokacin da ake ƙarawa / cire abubuwan tsararru, zancensa ba zai canza ba.

Mobx yana aiki azaman mai zaɓe a karon farko kuma yana ci gaba da lura da abubuwan lura waɗanda muka isa. Ana yin wannan ta hanyar masu karɓa. Saboda haka, ana amfani da aikin da aka gina a nan toJS. Yana dawo da sabon abu tare da duk wakilai waɗanda aka maye gurbinsu da filayen asali. A lokacin aiwatarwa, yana karanta duk filayen abu - don haka ana kunna masu getters.

A cikin na'ura mai ba da hanya tsakanin hanyoyin sadarwa za mu sake ƙara maɓallai da yawa. A wannan lokacin kuma sun ƙare a cikin Ma'ajiyar gida:

Rubutun tsawaita mai tsaro

Lokacin da aka sake loda shafin baya, bayanin yana nan a wurin.

Ana iya duba duk lambar aikace-aikacen har zuwa wannan batu a nan.

Amintaccen ma'ajiyar maɓallan sirri

Adana maɓallan sirri a cikin tsararren rubutu ba shi da haɗari: koyaushe akwai damar cewa za a yi kutse, samun damar shiga kwamfutarku, da sauransu. Don haka, a cikin Ma'ajiyar gida za mu adana maɓallan a cikin sigar rufaffen kalmar sirri.

Don ƙarin tsaro, za mu ƙara yanayin kulle zuwa aikace-aikacen, wanda ba za a sami damar shiga maɓallan kwata-kwata ba. Za mu canja wurin tsawo ta atomatik zuwa yanayin kulle saboda ɓata lokaci.

Mobx yana ba ku damar adana mafi ƙarancin saiti na bayanai, sauran kuma ana ƙididdige su ta atomatik akan sa. Waɗannan su ne abin da ake kira kaddarorin ƙididdiga. Ana iya kwatanta su da ra'ayoyi a cikin bayanan bayanai:

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

Yanzu muna adana maɓallan rufaffiyar da kalmar sirri kawai. Duk abin da aka lissafta. Muna yin canja wuri zuwa wani kulle-kulle ta hanyar cire kalmar sirri daga jihar. API ɗin jama'a yanzu yana da hanya don fara ma'ajiyar.

An rubuta don ɓoyewa amfani da 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)
}

Mai binciken yana da API mara amfani wanda ta inda zaku iya biyan kuɗi zuwa wani taron - canje-canjen yanayi. Jiha, bisa ga haka, na iya zama idle, active и locked. Don rashin aiki zaka iya saita lokacin ƙarewa, kuma ana saita kulle lokacin da OS kanta ke katange. Hakanan za mu canza mai zaɓi don adanawa zuwa Ajiye na gida:

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

Lambar kafin wannan mataki shine a nan.

Ma'amaloli

Don haka, mun zo ga abu mafi mahimmanci: ƙirƙira da sanya hannu kan ma'amaloli akan blockchain. Za mu yi amfani da WAVES blockchain da ɗakin karatu taguwar ruwa-ma'amaloli.

Da farko, bari mu ƙara wa jihar saƙon da ke buƙatar sanya hannu, sannan mu ƙara hanyoyin ƙara sabon saƙo, tabbatar da sa hannun, da ƙi:

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

    ...
}

Lokacin da muka karɓi sabon saƙo, muna ƙara metadata zuwa gare shi, yi observable kuma ƙara zuwa store.messages.

Idan ba haka ba observable da hannu, sa'an nan mobx zai yi shi da kanta lokacin da ƙara saƙonni a cikin tsararru. Koyaya, zai haifar da sabon abu wanda ba za mu sami ambatonsa ba, amma za mu buƙaci shi don mataki na gaba.

Na gaba, za mu dawo da alkawarin da ke warwarewa lokacin da yanayin saƙon ya canza. Ana kula da matsayin ta hanyar amsawa, wanda zai "kashe kansa" lokacin da yanayin ya canza.

Lambar hanya approve и reject mai sauqi qwarai: kawai mu canza matsayin saƙon, bayan sanya hannu idan ya cancanta.

Mun sanya Amincewa da ƙi a cikin UI API, sabon Saƙo a cikin API ɗin shafi:

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

    ...
}

Yanzu bari mu yi ƙoƙarin sanya hannu kan ma'amala tare da kari:

Rubutun tsawaita mai tsaro

Gabaɗaya, duk abin yana shirye, duk abin da ya rage shine ƙara UI mai sauƙi.

UI

Mai dubawa yana buƙatar samun dama ga yanayin aikace-aikacen. A gefen UI za mu yi observable jihar kuma ƙara aiki zuwa API wanda zai canza wannan yanayin. Mu kara observable zuwa abin API da aka karɓa daga bango:

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

A karshen za mu fara samar da aikace-aikace dubawa. Wannan aikace-aikacen amsawa ne. Abu na baya ana wucewa kawai ta amfani da kayan aiki. Zai zama daidai, ba shakka, don yin sabis na daban don hanyoyin da kantin sayar da kayayyaki don jihar, amma don dalilan wannan labarin wannan ya isa:

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

Tare da mobx yana da sauƙin farawa lokacin da bayanai suka canza. Muna kawai rataye kayan ado na kallo daga kunshin mobx - amsa akan bangaren, kuma za a kira mai bayarwa ta atomatik lokacin da duk wani abin lura da sashin ya canza. Ba kwa buƙatar kowane taswiraStateToProps ko haɗi kamar a redux. Komai yana aiki daidai daga akwatin:

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

Za a iya duba sauran abubuwan da suka rage a cikin lambar a cikin babban fayil na UI.

Yanzu a cikin ajin aikace-aikacen kuna buƙatar yin zaɓi na jiha don UI kuma sanar da UI lokacin da ya canza. Don yin wannan, bari mu ƙara hanya getState и reactionkira 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())

        })
    }

    ...
}

Lokacin karbar abu remote ake halitta reaction don canza yanayin da ke kiran aikin a gefen UI.

Taɓawar ƙarshe shine ƙara nunin sabbin saƙonni akan gunkin tsawo:

function setupApp() {
...

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

...
}

Don haka, aikace-aikacen yana shirye. Shafukan yanar gizon na iya buƙatar sa hannu don ma'amala:

Rubutun tsawaita mai tsaro

Rubutun tsawaita mai tsaro

Ana samun lambar a nan mahada.

ƙarshe

Idan kun karanta labarin har ƙarshe, amma har yanzu kuna da tambayoyi, kuna iya yin su a wuraren ajiya tare da tsawo. A can kuma za ku sami alkawuran kowane mataki da aka keɓance.

Kuma idan kuna sha'awar kallon lambar don ainihin tsawo, za ku iya samun wannan a nan.

Code, ma'ajiya da bayanin aiki daga siemarell

source: www.habr.com

Add a comment