Kuandika kiendelezi salama cha kivinjari

Kuandika kiendelezi salama cha kivinjari

Tofauti na usanifu wa kawaida wa "seva-mteja", programu zilizogatuliwa zina sifa ya:

  • Hakuna haja ya kuhifadhi hifadhidata na logi za mtumiaji na nywila. Maelezo ya ufikiaji yanahifadhiwa pekee na watumiaji wenyewe, na uthibitisho wa uhalisi wao hutokea katika kiwango cha itifaki.
  • Hakuna haja ya kutumia seva. Mantiki ya maombi inaweza kutekelezwa kwenye mtandao wa blockchain, ambapo inawezekana kuhifadhi kiasi kinachohitajika cha data.

Kuna hifadhi 2 salama kiasi za funguo za mtumiaji - pochi za maunzi na viendelezi vya kivinjari. Pochi za maunzi mara nyingi ni salama sana, lakini ni vigumu kutumia na ziko mbali na bure, lakini viendelezi vya kivinjari ni mchanganyiko kamili wa usalama na urahisi wa utumiaji, na pia vinaweza kuwa bure kabisa kwa watumiaji wa mwisho.

Kwa kuzingatia haya yote, tulitaka kufanya kiendelezi salama zaidi ambacho hurahisisha uundaji wa programu zilizogatuliwa kwa kutoa API rahisi ya kufanya kazi na miamala na sahihi.
Tutakuambia kuhusu uzoefu huu hapa chini.

Nakala hiyo itakuwa na maagizo ya hatua kwa hatua juu ya jinsi ya kuandika kiendelezi cha kivinjari, na mifano ya nambari na picha za skrini. Unaweza kupata msimbo wote ndani hazina. Kila ahadi kimantiki inalingana na sehemu ya kifungu hiki.

Historia Fupi ya Viendelezi vya Kivinjari

Viendelezi vya kivinjari vimekuwepo kwa muda mrefu. Walionekana kwenye Internet Explorer nyuma mnamo 1999, huko Firefox mnamo 2004. Hata hivyo, kwa muda mrefu sana hapakuwa na kiwango kimoja cha upanuzi.

Tunaweza kusema kwamba ilionekana pamoja na viendelezi katika toleo la nne la Google Chrome. Kwa kweli, hakukuwa na maelezo wakati huo, lakini ilikuwa API ya Chrome ambayo ikawa msingi wake: baada ya kushinda soko kubwa la kivinjari na kuwa na duka la programu iliyojengwa, Chrome iliweka kiwango cha upanuzi wa kivinjari.

Mozilla ilikuwa na kiwango chake, lakini kwa kuona umaarufu wa upanuzi wa Chrome, kampuni iliamua kufanya API inayolingana. Mnamo mwaka wa 2015, kwa mpango wa Mozilla, kikundi maalum kiliundwa ndani ya Jumuiya ya Ulimwenguni Pote ya Wavuti (W3C) kufanya kazi juu ya vipimo vya upanuzi wa kivinjari.

Viendelezi vya API vilivyopo vya Chrome vilichukuliwa kama msingi. Kazi hiyo ilifanywa kwa msaada wa Microsoft (Google ilikataa kushiriki katika ukuzaji wa kiwango), na matokeo yake rasimu ilionekana. vipimo.

Rasmi, vipimo vinaungwa mkono na Edge, Firefox na Opera (kumbuka kuwa Chrome haiko kwenye orodha hii). Lakini kwa kweli, kiwango hicho kinaendana kwa kiasi kikubwa na Chrome, kwani kwa kweli imeandikwa kulingana na upanuzi wake. Unaweza kusoma zaidi kuhusu API ya WebExtensions hapa.

Muundo wa ugani

Faili pekee inayohitajika kwa kiendelezi ni faili ya maelezo (manifest.json). Pia ni "mahali pa kuingilia" kwa upanuzi.

Ilani

Kulingana na vipimo, faili ya maelezo ni faili halali ya JSON. Ufafanuzi kamili wa vitufe vya faili ya maelezo na maelezo kuhusu funguo zipi zinatumika ambamo kivinjari kinaweza kutazamwa hapa.

Vifunguo ambavyo haviko katika uainishaji "huenda" kupuuzwa (hitilafu za ripoti za Chrome na Firefox, lakini viendelezi vinaendelea kufanya kazi).

Na ningependa kuzingatia baadhi ya pointi.

  1. background - kitu ambacho kinajumuisha nyanja zifuatazo:
    1. scripts - safu ya maandishi ambayo yatatekelezwa katika muktadha wa nyuma (tutazungumza juu ya hili baadaye kidogo);
    2. ukurasa - badala ya hati ambazo zitatekelezwa katika ukurasa usio na kitu, unaweza kubainisha html na maudhui. Katika kesi hii, uga wa hati utapuuzwa, na hati zitahitajika kuingizwa kwenye ukurasa wa maudhui;
    3. kuendelea - bendera ya binary, ikiwa haijabainishwa, kivinjari "kitaua" mchakato wa usuli wakati inazingatia kuwa haifanyi chochote, na kuianzisha tena ikiwa ni lazima. Vinginevyo, ukurasa utapakuliwa tu wakati kivinjari kimefungwa. Haitumiki katika Firefox.
  2. maandishi_ya_maudhui — safu ya vitu ambayo hukuruhusu kupakia hati tofauti kwenye kurasa tofauti za wavuti. Kila kitu kina nyanja muhimu zifuatazo:
    1. mechi - url ya muundo, ambayo huamua ikiwa hati fulani ya maudhui itajumuishwa au la.
    2. js - orodha ya maandishi ambayo yatapakiwa kwenye mechi hii;
    3. ondoa_mechi - haijumuishi kutoka shambani match URL zinazolingana na sehemu hii.
  3. ukurasa_kitendo - kwa kweli ni kitu ambacho kinawajibika kwa ikoni inayoonyeshwa karibu na upau wa anwani kwenye kivinjari na mwingiliano nayo. Pia hukuruhusu kuonyesha dirisha ibukizi, ambalo linafafanuliwa kwa kutumia HTML, CSS na JS yako mwenyewe.
    1. ibukizi_chaguo-msingi — njia ya faili ya HTML iliyo na kiolesura ibukizi, inaweza kuwa na CSS na JS.
  4. ruhusa - safu ya kusimamia haki za ugani. Kuna aina 3 za haki, ambazo zimeelezwa kwa undani hapa
  5. rasilimali_zinazoweza kufikiwa - rasilimali za ugani ambazo ukurasa wa wavuti unaweza kuomba, kwa mfano, picha, JS, CSS, faili za HTML.
  6. kuunganishwa_nje — hapa unaweza kubainisha kwa uwazi vitambulisho vya viendelezi vingine na vikoa vya kurasa za wavuti ambazo unaweza kuunganisha. Kikoa kinaweza kuwa ngazi ya pili au zaidi. Haifanyi kazi katika Firefox.

Muktadha wa utekelezaji

Ugani una miktadha mitatu ya utekelezaji wa msimbo, yaani, programu ina sehemu tatu na viwango tofauti vya upatikanaji wa API ya kivinjari.

Muktadha wa kiendelezi

API nyingi zinapatikana hapa. Katika muktadha huu "wanaishi":

  1. Ukurasa wa usuli — sehemu ya "backend" ya kiendelezi. Faili imebainishwa katika faili ya maelezo kwa kutumia kitufe cha "chinichini".
  2. Ukurasa ibukizi — ukurasa ibukizi unaoonekana unapobofya ikoni ya kiendelezi. Katika ilani browser_action -> default_popup.
  3. Ukurasa maalum — ukurasa wa kiendelezi, "unaoishi" katika kichupo tofauti cha mwonekano chrome-extension://<id_расширения>/customPage.html.

Muktadha huu upo bila kutegemea madirisha na vichupo vya kivinjari. Ukurasa wa usuli ipo katika nakala moja na hufanya kazi kila wakati (isipokuwa ni ukurasa wa tukio, wakati hati ya usuli inapozinduliwa na tukio na "kufa" baada ya utekelezaji wake). Ukurasa ibukizi ipo wakati dirisha ibukizi limefunguliwa, na Ukurasa maalum - wakati kichupo kilicho nacho kimefunguliwa. Hakuna ufikiaji wa vichupo vingine na yaliyomo kutoka kwa muktadha huu.

Muktadha wa hati ya yaliyomo

Faili ya hati ya yaliyomo imezinduliwa pamoja na kila kichupo cha kivinjari. Inaweza kufikia sehemu ya API ya kiendelezi na kwa mti wa DOM wa ukurasa wa wavuti. Ni maandishi ya yaliyomo ambayo yanawajibika kwa mwingiliano na ukurasa. Viendelezi vinavyotumia mti wa DOM hufanya hivi katika hati za maudhui - kwa mfano, vizuizi vya matangazo au watafsiri. Pia, maandishi ya yaliyomo yanaweza kuwasiliana na ukurasa kupitia kiwango postMessage.

Muktadha wa ukurasa wa wavuti

Huu ndio ukurasa halisi wa wavuti wenyewe. Haihusiani na kiendelezi na haina ufikiaji hapo, isipokuwa katika hali ambapo kikoa cha ukurasa huu hakijaonyeshwa kwa uwazi kwenye faili ya maelezo (zaidi kuhusu hii hapa chini).

Kubadilishana ujumbe

Sehemu tofauti za programu lazima zibadilishane ujumbe. Kuna API ya hii runtime.sendMessage kutuma ujumbe background и tabs.sendMessage kutuma ujumbe kwa ukurasa (hati ya yaliyomo, ibukizi au ukurasa wa wavuti ikiwa inapatikana externally_connectable) Chini ni mfano wakati wa kufikia API ya 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))
    }
)

Kwa mawasiliano kamili, unaweza kuunda miunganisho kupitia runtime.connect. Kwa majibu tutapokea runtime.Port, ambayo, wakati imefunguliwa, unaweza kutuma idadi yoyote ya ujumbe. Kwa upande wa mteja, kwa mfano, contentscript, inaonekana kama hii:

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

Seva au usuli:

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

Pia kuna tukio onDisconnect na mbinu disconnect.

Mchoro wa maombi

Wacha tutengeneze kiendelezi cha kivinjari ambacho huhifadhi funguo za kibinafsi, hutoa ufikiaji wa habari ya umma (anwani, ufunguo wa umma huwasiliana na ukurasa na huruhusu programu za watu wengine kuomba saini kwa miamala.

Maendeleo ya maombi

Programu yetu lazima iingiliane na mtumiaji na kuupa ukurasa API ya kupiga simu (kwa mfano, kutia sahihi shughuli). Fanya jambo na moja tu contentscript haitafanya kazi, kwani ina ufikiaji wa DOM tu, lakini sio kwa JS ya ukurasa. Unganisha kupitia runtime.connect hatuwezi, kwa sababu API inahitajika kwenye vikoa vyote, na ni maalum pekee ndizo zinazoweza kubainishwa kwenye faili ya maelezo. Kama matokeo, mchoro utaonekana kama hii:

Kuandika kiendelezi salama cha kivinjari

Kutakuwa na hati nyingine - inpage, ambayo tutaingiza kwenye ukurasa. Itaendesha katika muktadha wake na kutoa API ya kufanya kazi na kiendelezi.

mwanzo

Msimbo wote wa kiendelezi wa kivinjari unapatikana kwa GitHub. Wakati wa maelezo kutakuwa na viungo vya kufanya.

Wacha tuanze na manifesto:

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

Unda background.js tupu, popup.js, inpage.js na contentscript.js. Tunaongeza popup.html - na programu yetu inaweza tayari kupakiwa kwenye Google Chrome na kuhakikisha kuwa inafanya kazi.

Ili kuthibitisha hili, unaweza kuchukua msimbo hivyo. Kwa kuongezea tulichofanya, kiunga kilisanidi mkusanyiko wa mradi kwa kutumia pakiti ya wavuti. Ili kuongeza programu kwenye kivinjari, katika chrome://extensions unahitaji kuchagua mzigo unpacked na folda na ugani sambamba - kwa upande wetu dist.

Kuandika kiendelezi salama cha kivinjari

Sasa ugani wetu umewekwa na kufanya kazi. Unaweza kuendesha zana za msanidi programu kwa muktadha tofauti kama ifuatavyo:

ibukizi ->

Kuandika kiendelezi salama cha kivinjari

Ufikiaji wa kiweko cha hati ya yaliyomo unafanywa kupitia koni ya ukurasa yenyewe ambayo imezinduliwa.Kuandika kiendelezi salama cha kivinjari

Kubadilishana ujumbe

Kwa hivyo, tunahitaji kuanzisha njia mbili za mawasiliano: inpage <-> usuli na dukizi <-> usuli. Unaweza, kwa kweli, kutuma ujumbe tu kwa bandari na kuvumbua itifaki yako mwenyewe, lakini napendelea njia ambayo niliona kwenye mradi wa chanzo wazi wa metamask.

Hii ni kiendelezi cha kivinjari cha kufanya kazi na mtandao wa Ethereum. Ndani yake, sehemu tofauti za programu huwasiliana kupitia RPC kwa kutumia maktaba ya dnode. Inakuruhusu kupanga ubadilishanaji haraka na kwa urahisi ikiwa utaipatia mkondo wa nodejs kama usafirishaji (ikimaanisha kitu kinachotumia kiolesura sawa):

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

Sasa tutaunda darasa la maombi. Itaunda vitu vya API kwa kidukizo na ukurasa wa wavuti, na kuunda dnode kwao:

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

Hapa na chini, badala ya kipengee cha kimataifa cha Chrome, tunatumia extensionApi, ambayo inafikia Chrome katika kivinjari cha Google na kivinjari katika vingine. Hii inafanywa kwa uoanifu wa vivinjari tofauti, lakini kwa madhumuni ya kifungu hiki mtu anaweza kutumia 'chrome.runtime.connect'.

Wacha tuunde mfano wa programu kwenye hati ya nyuma:

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

Kwa kuwa dnode inafanya kazi na mito, na tunapokea bandari, darasa la adapta inahitajika. Inafanywa kwa kutumia maktaba inayoweza kusomeka, ambayo hutekelezea mitiririko ya nodejs kwenye kivinjari:

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

Sasa wacha tuunde muunganisho katika 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;
    }
}

Kisha tunaunda unganisho katika hati ya yaliyomo:

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

Kwa kuwa tunahitaji API sio kwenye hati ya yaliyomo, lakini moja kwa moja kwenye ukurasa, tunafanya mambo mawili:

  1. Tunaunda mito miwili. Moja - kuelekea ukurasa, juu ya postMessage. Kwa hili tunatumia hii kifurushi hiki kutoka kwa waundaji wa metamask. Mtiririko wa pili ni wa chinichini juu ya mlango uliopokelewa kutoka runtime.connect. Hebu tununue. Sasa ukurasa utakuwa na mkondo kwa usuli.
  2. Ingiza hati kwenye DOM. Pakua hati (ufikiaji wake uliruhusiwa katika faili ya maelezo) na uunde lebo script na yaliyomo ndani:

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

Sasa tunaunda kitu cha api katika inpage na kuiweka kimataifa:

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

Tuko tayari Simu ya Utaratibu wa Mbali (RPC) yenye API tofauti ya ukurasa na UI. Wakati wa kuunganisha ukurasa mpya kwa usuli tunaweza kuona hii:

Kuandika kiendelezi salama cha kivinjari

API tupu na asili. Kwa upande wa ukurasa, tunaweza kuita kazi ya hello kama hii:

Kuandika kiendelezi salama cha kivinjari

Kufanya kazi na vitendaji vya kupiga simu katika JS ya kisasa ni tabia mbaya, kwa hivyo wacha tuandike msaidizi mdogo kuunda dnode ambayo hukuruhusu kupitisha kitu cha API kwa matumizi.

Vitu vya API sasa vitaonekana kama hii:

export class SignerApp {

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

...

}

Kupata kitu kutoka kwa mbali kama hii:

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

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

Na kazi za kupiga simu hurudisha ahadi:

Kuandika kiendelezi salama cha kivinjari

Toleo lililo na vitendaji vya asynchronous linapatikana hapa.

Kwa ujumla, RPC na mbinu ya mtiririko inaonekana kunyumbulika kabisa: tunaweza kutumia kuzidisha kwa mvuke na kuunda API kadhaa tofauti kwa kazi tofauti. Kimsingi, dnode inaweza kutumika popote, jambo kuu ni kuifunga usafiri kwa namna ya mkondo wa nodejs.

Njia mbadala ni umbizo la JSON, linalotumia itifaki ya JSON RPC 2. Hata hivyo, inafanya kazi na uchukuzi mahususi (TCP na HTTP(S)), ambao hautumiki katika hali yetu.

Jimbo la ndani na Hifadhi ya ndani

Tutahitaji kuhifadhi hali ya ndani ya programu - angalau funguo za kusaini. Tunaweza kuongeza hali kwa urahisi kwenye programu na mbinu za kuibadilisha kwenye API ibukizi:

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

    ...

} 

Huku nyuma, tutafunga kila kitu kwenye chaguo la kukokotoa na kuandika kitu cha programu kwenye dirisha ili tuweze kufanya kazi nacho kutoka kwa koni:

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

Wacha tuongeze funguo chache kutoka kwa kiweko cha UI na tuone kinachotokea kwa hali:

Kuandika kiendelezi salama cha kivinjari

Hali inahitaji kufanywa kuendelea ili funguo zisipotee wakati wa kuanzisha upya.

Tutaihifadhi katika Hifadhi ya ndani, tukibatilisha kwa kila mabadiliko. Baadaye, ufikiaji wake pia utahitajika kwa UI, na ningependa pia kujiandikisha kwa mabadiliko. Kulingana na hili, itakuwa rahisi kuunda hifadhi inayoonekana na kujiandikisha kwa mabadiliko yake.

Tutatumia maktaba ya mobx (https://github.com/mobxjs/mobx) Chaguo lilianguka juu yake kwa sababu sikulazimika kufanya kazi nayo, lakini nilitaka sana kuisoma.

Wacha tuongeze uanzishaji wa hali ya awali na ufanye duka lionekane:

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

    ...

}

"Chini ya kofia," mobx imebadilisha sehemu zote za duka kwa kutumia seva mbadala na inakata simu zote kwao. Itawezekana kujiandikisha kwa ujumbe huu.

Hapo chini nitatumia neno "wakati wa kubadilisha", ingawa hii sio sahihi kabisa. Mobx hufuatilia ufikiaji wa sehemu. Getters na seti ya vitu vya wakala ambavyo maktaba huunda hutumiwa.

Mapambo ya hatua hutumikia madhumuni mawili:

  1. Katika hali kali iliyo na bendera ya Vitendo vya kutekeleza, mobx inakataza kubadilisha hali moja kwa moja. Inachukuliwa kuwa mazoezi mazuri ya kufanya kazi chini ya hali ngumu.
  2. Hata kama kipengele cha kukokotoa kitabadilisha hali mara kadhaa - kwa mfano, tunabadilisha nyanja kadhaa katika mistari kadhaa ya nambari - waangalizi wanaarifiwa tu inapokamilika. Hii ni muhimu sana kwa sehemu ya mbele, ambapo sasisho zisizo za lazima za hali husababisha utoaji usio wa lazima wa vipengele. Kwa upande wetu, sio ya kwanza wala ya pili ni muhimu sana, lakini tutafuata mazoea bora. Ni desturi ya kuunganisha wapambaji kwa kazi zote zinazobadilisha hali ya mashamba yaliyozingatiwa.

Huku nyuma tutaongeza uanzishaji na kuhifadhi hali katika Uhifadhi wa ndani:

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

Kitendaji cha mwitikio kinavutia hapa. Ina hoja mbili:

  1. Kiteuzi cha data.
  2. Kidhibiti ambacho kitaitwa na data hii kila wakati inapobadilika.

Tofauti na redux, ambapo tunapokea hali kwa uwazi kama hoja, mobx hukumbuka ni vitu vipi vinavyoonekana tunapata ndani ya kiteuzi, na huita tu kidhibiti vinapobadilika.

Ni muhimu kuelewa haswa jinsi mobx huamua ni vitu vipi vinavyoonekana tunavyojisajili. Ikiwa niliandika kichaguzi kwa nambari kama hii() => app.store, basi majibu hayatawahi kuitwa, kwani hifadhi yenyewe haionekani, ni mashamba yake tu.

Kama niliandika hivi () => app.store.keys, basi tena hakuna kitakachotokea, kwani wakati wa kuongeza/kuondoa vitu vya safu, kumbukumbu yake haitabadilika.

Mobx hufanya kazi kama kiteuzi kwa mara ya kwanza na hufuatilia tu mambo yanayoonekana ambayo tulifikia. Hii inafanywa kupitia wapataji wakala. Kwa hiyo, kazi iliyojengwa hutumiwa hapa toJS. Inarudisha kitu kipya na proksi zote zilizobadilishwa na sehemu asili. Wakati wa utekelezaji, inasoma nyanja zote za kitu - kwa hivyo wapataji husababishwa.

Katika console ya kidukizo tutaongeza tena funguo kadhaa. Wakati huu pia waliingia kwenye Hifadhi ya ndani:

Kuandika kiendelezi salama cha kivinjari

Wakati ukurasa wa usuli unapopakiwa upya, taarifa hubaki mahali pake.

Nambari zote za maombi hadi kufikia hatua hii zinaweza kutazamwa hapa.

Hifadhi salama ya funguo za kibinafsi

Kuhifadhi funguo za kibinafsi katika maandishi wazi sio salama: daima kuna nafasi ya kuwa utadukuliwa, kupata upatikanaji wa kompyuta yako, na kadhalika. Kwa hiyo, katika Uhifadhi wa ndani tutahifadhi funguo katika fomu iliyosimbwa kwa nenosiri.

Kwa usalama zaidi, tutaongeza hali iliyofungwa kwa programu, ambayo hakutakuwa na ufikiaji wa funguo kabisa. Tutahamisha kiendelezi kiotomatiki kwa hali iliyofungwa kwa sababu ya kuisha kwa muda.

Mobx hukuruhusu kuhifadhi data ya chini tu, na iliyobaki huhesabiwa kiotomatiki kulingana nayo. Hizi ni sifa zinazoitwa computed. Wanaweza kulinganishwa na maoni katika hifadhidata:

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

Sasa tunahifadhi tu funguo zilizosimbwa na nenosiri. Kila kitu kingine kinahesabiwa. Tunafanya uhamisho kwenye hali iliyofungwa kwa kuondoa nenosiri kutoka kwa serikali. API ya umma sasa ina mbinu ya kuanzisha hifadhi.

Imeandikwa kwa usimbaji fiche huduma kwa kutumia 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)
}

Kivinjari kina API isiyofanya kazi ambayo unaweza kujiandikisha kwa tukio - mabadiliko ya hali. Jimbo, ipasavyo, inaweza kuwa idle, active и locked. Kwa uvivu unaweza kuweka muda wa kuisha, na imefungwa imewekwa wakati OS yenyewe imezuiwa. Pia tutabadilisha kiteuzi cha kuhifadhi kuwa LocalStore:

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

Kanuni kabla ya hatua hii ni hapa.

Shughuli

Kwa hiyo, tunakuja kwa jambo muhimu zaidi: kuunda na kusaini shughuli kwenye blockchain. Tutatumia WAVES blockchain na maktaba mawimbi-shughuli.

Kwanza, hebu tuongeze kwenye hali safu ya ujumbe unaohitaji kutiwa saini, kisha tuongeze mbinu za kuongeza ujumbe mpya, kuthibitisha saini, na kukataa:

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

    ...
}

Tunapopokea ujumbe mpya, tunaongeza metadata kwake, fanya observable na kuongeza store.messages.

Kama huna observable kwa mikono, basi mobx itafanya yenyewe wakati wa kuongeza ujumbe kwenye safu. Hata hivyo, itaunda kitu kipya ambacho hatutakuwa na kumbukumbu, lakini tutaihitaji kwa hatua inayofuata.

Ifuatayo, tunarudisha ahadi ambayo hutatuliwa wakati hali ya ujumbe inabadilika. Hali inafuatiliwa na majibu, ambayo "itajiua" wakati hali inabadilika.

Msimbo wa njia approve и reject rahisi sana: tunabadilisha tu hali ya ujumbe, baada ya kusaini ikiwa ni lazima.

Tunaweka Idhini na kukataa katika API ya UI, newMessage kwenye API ya ukurasa:

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

    ...
}

Sasa hebu tujaribu kusaini shughuli na kiendelezi:

Kuandika kiendelezi salama cha kivinjari

Kwa ujumla, kila kitu kiko tayari, kilichobaki ni ongeza UI rahisi.

UI

Kiolesura kinahitaji ufikiaji wa hali ya programu. Kwa upande wa UI tutafanya observable state na kuongeza kazi kwa API ambayo itabadilisha hali hii. Hebu tuongeze observable kwa kitu cha API kilichopokelewa kutoka kwa nyuma:

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

Mwishoni tunaanza kutoa kiolesura cha programu. Hii ni maombi ya kuguswa. Kipengee cha mandharinyuma kinapitishwa tu kwa kutumia props. Itakuwa sahihi, kwa kweli, kufanya huduma tofauti kwa njia na duka la serikali, lakini kwa madhumuni ya kifungu hiki inatosha:

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

Ukiwa na mobx ni rahisi sana kuanza kutoa data inapobadilika. Tunapachika tu mpambaji wa mwangalizi kutoka kwa kifurushi mobx-react kwenye kijenzi, na kutoa itaitwa kiotomatiki wakati mambo yoyote yanayoonekana yanayorejelewa na sehemu yanabadilika. Huhitaji mapStateToProps yoyote au kuunganisha kama katika redux. Kila kitu hufanya kazi nje ya boksi:

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

Vipengele vilivyobaki vinaweza kutazamwa kwenye nambari kwenye folda ya UI.

Sasa katika darasa la maombi unahitaji kufanya kichaguzi cha hali kwa UI na kuarifu UI inapobadilika. Ili kufanya hivyo, hebu tuongeze njia getState и reactionwito 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())

        })
    }

    ...
}

Wakati wa kupokea kitu remote imeundwa reaction kubadilisha hali inayoita chaguo za kukokotoa kwenye upande wa UI.

Mguso wa mwisho ni kuongeza onyesho la ujumbe mpya kwenye ikoni ya kiendelezi:

function setupApp() {
...

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

...
}

Kwa hivyo, maombi iko tayari. Kurasa za wavuti zinaweza kuomba sahihi kwa miamala:

Kuandika kiendelezi salama cha kivinjari

Kuandika kiendelezi salama cha kivinjari

Msimbo unapatikana hapa kiungo.

Hitimisho

Ikiwa umesoma makala hadi mwisho, lakini bado una maswali, unaweza kuwauliza hazina zilizo na ugani. Huko pia utapata ahadi kwa kila hatua iliyoteuliwa.

Na ikiwa una nia ya kuangalia msimbo wa ugani halisi, unaweza kupata hii hapa.

Kanuni, hazina na maelezo ya kazi kutoka siemarell

Chanzo: mapenzi.com

Kuongeza maoni