Droša pārlūkprogrammas paplašinājuma rakstīšana

Droša pārlūkprogrammas paplašinājuma rakstīšana

Atšķirībā no parastās “klienta-servera” arhitektūras, decentralizētajām lietojumprogrammām ir raksturīgas:

  • Nav nepieciešams uzglabāt datu bāzi ar lietotāju pieteikumvārdiem un parolēm. Piekļuves informāciju glabā tikai paši lietotāji, un to autentiskums tiek apstiprināts protokola līmenī.
  • Nav nepieciešams izmantot serveri. Lietojumprogrammu loģiku var izpildīt blokķēdes tīklā, kur iespējams uzglabāt nepieciešamo datu apjomu.

Lietotāju atslēgām ir 2 salīdzinoši drošas krātuves – aparatūras maki un pārlūkprogrammas paplašinājumi. Aparatūras maki lielākoties ir ārkārtīgi droši, taču grūti lietojami un nebūt nav bez maksas, taču pārlūkprogrammas paplašinājumi ir ideāla drošības un lietošanas vienkāršības kombinācija, kā arī tiešajiem lietotājiem var būt pilnīgi bez maksas.

Ņemot to visu vērā, mēs vēlējāmies izveidot visdrošāko paplašinājumu, kas vienkāršo decentralizētu lietojumprogrammu izstrādi, nodrošinot vienkāršu API darbam ar darījumiem un parakstiem.
Tālāk mēs jums pastāstīsim par šo pieredzi.

Rakstā būs ietverti detalizēti norādījumi par to, kā rakstīt pārlūkprogrammas paplašinājumu, ar koda piemēriem un ekrānuzņēmumiem. Jūs varat atrast visu kodu krātuves. Katra apņemšanās loģiski atbilst šī raksta sadaļai.

Īsa pārlūkprogrammas paplašinājumu vēsture

Pārlūkprogrammu paplašinājumi ir bijuši jau ilgu laiku. Tie parādījās pārlūkprogrammā Internet Explorer 1999. gadā, bet pārlūkprogrammā Firefox — 2004. gadā. Tomēr ļoti ilgu laiku nebija vienota paplašinājumu standarta.

Var teikt, ka tas parādījās kopā ar paplašinājumiem Google Chrome ceturtajā versijā. Protams, toreiz nebija nekādu specifikāciju, taču Chrome API kļuva par tās pamatu: iekarojot lielāko daļu pārlūkprogrammu tirgus un kam ir iebūvēts lietojumprogrammu veikals, Chrome faktiski noteica pārlūka paplašinājumu standartu.

Mozilla bija savs standarts, taču, redzot Chrome paplašinājumu popularitāti, uzņēmums nolēma izveidot saderīgu API. 2015. gadā pēc Mozilla iniciatīvas World Wide Web Consortium (W3C) ietvaros tika izveidota īpaša grupa, lai strādātu pie starppārlūkprogrammu paplašinājumu specifikācijām.

Par pamatu tika ņemti esošie Chrome API paplašinājumi. Darbs tika veikts ar Microsoft atbalstu (Google atteicās piedalīties standarta izstrādē), un rezultātā parādījās melnraksts specifikācijas.

Formāli specifikāciju atbalsta Edge, Firefox un Opera (ņemiet vērā, ka Chrome šajā sarakstā nav). Bet patiesībā standarts lielā mērā ir saderīgs ar Chrome, jo tas faktiski ir uzrakstīts, pamatojoties uz tā paplašinājumiem. Varat lasīt vairāk par WebExtensions API šeit.

Pagarinājuma struktūra

Vienīgais fails, kas nepieciešams paplašinājumam, ir manifests (manifest.json). Tas ir arī “ieejas punkts” paplašināšanai.

Manifestu

Saskaņā ar specifikāciju manifesta fails ir derīgs JSON fails. Pilns manifesta atslēgu apraksts ar informāciju par to, kuras atslēgas tiek atbalstītas kādā pārlūkprogrammā šeit.

Atslēgas, kas nav norādītas specifikācijā, var tikt ignorētas (gan Chrome, gan Firefox ziņo par kļūdām, taču paplašinājumi turpina darboties).

Un es gribētu pievērst uzmanību dažiem punktiem.

  1. fons — objekts, kas ietver šādus laukus:
    1. skripti — skriptu masīvs, kas tiks izpildīts fona kontekstā (par to mēs runāsim nedaudz vēlāk);
    2. lappuse - skriptu vietā, kas tiks izpildīti tukšā lapā, varat norādīt html ar saturu. Šādā gadījumā skripta lauks tiks ignorēts, un skripti būs jāievieto satura lapā;
    3. saglabājas — binārais karodziņš, ja tas nav norādīts, pārlūkprogramma “nogalinās” fona procesu, ja uzskatīs, ka tā neko nedara, un vajadzības gadījumā to restartēs. Pretējā gadījumā lapa tiks izlādēta tikai tad, kad pārlūkprogramma tiks aizvērta. Netiek atbalstīts pārlūkprogrammā Firefox.
  2. satura_skripti — objektu masīvs, kas ļauj ielādēt dažādus skriptus dažādās tīmekļa lapās. Katrs objekts satur šādus svarīgus laukus:
    1. sērkociņi Sākot no modeļa URL, kas nosaka, vai konkrēta satura skripts tiks iekļauts vai nē.
    2. js — skriptu saraksts, kas tiks ielādēti šajā spēlē;
    3. izslēgt_atbilstības - izslēdz no lauka match URL, kas atbilst šim laukam.
  3. page_action - patiesībā ir objekts, kas ir atbildīgs par ikonu, kas tiek rādīta blakus adreses joslai pārlūkprogrammā, un mijiedarbību ar to. Tas arī ļauj parādīt uznirstošo logu, kas ir definēts, izmantojot jūsu HTML, CSS un JS.
    1. noklusējuma_uznirstošais logs — ceļš uz HTML failu ar uznirstošo interfeisu, var saturēt CSS un JS.
  4. Atļaujas — masīvs paplašinājuma tiesību pārvaldībai. Ir 3 tiesību veidi, kas ir detalizēti aprakstīti šeit
  5. tīmekļa_pieejami_resursi — paplašinājumu resursi, ko tīmekļa lapa var pieprasīt, piemēram, attēli, JS, CSS, HTML faili.
  6. ārēji_pieslēdzams — šeit varat skaidri norādīt citu paplašinājumu ID un tīmekļa lapu domēnus, no kuriem varat izveidot savienojumu. Domēns var būt otrā līmeņa vai augstāks. Nedarbojas pārlūkprogrammā Firefox.

Izpildes konteksts

Paplašinājumam ir trīs koda izpildes konteksti, tas ir, lietojumprogramma sastāv no trim daļām ar dažādiem piekļuves līmeņiem pārlūkprogrammas API.

Paplašinājuma konteksts

Lielākā daļa API ir pieejama šeit. Šajā kontekstā viņi "dzīvo":

  1. Fona lapa — paplašinājuma “backend” daļa. Fails ir norādīts manifestā, izmantojot taustiņu “fons”.
  2. Uznirstošā lapa — uznirstošā lapa, kas tiek parādīta, noklikšķinot uz paplašinājuma ikonas. Manifestā browser_action -> default_popup.
  3. Pielāgota lapa — paplašinājuma lapa, “dzīvo” atsevišķā skata cilnē chrome-extension://<id_расширения>/customPage.html.

Šis konteksts pastāv neatkarīgi no pārlūkprogrammas logiem un cilnēm. Fona lapa pastāv vienā eksemplārā un vienmēr darbojas (izņēmums ir notikuma lapa, kad fona skripts tiek palaists notikuma rezultātā un pēc tā izpildes “nomirst”). Uznirstošā lapa pastāv, kad ir atvērts uznirstošais logs, un Pielāgota lapa — kamēr cilne ar to ir atvērta. No šī konteksta nav piekļuves citām cilnēm un to saturam.

Satura skripta konteksts

Satura skripta fails tiek palaists kopā ar katru pārlūkprogrammas cilni. Tam ir piekļuve daļai no paplašinājuma API un tīmekļa lapas DOM kokam. Tie ir satura skripti, kas ir atbildīgi par mijiedarbību ar lapu. Paplašinājumi, kas manipulē ar DOM koku, to dara satura skriptos, piemēram, reklāmu bloķētāji vai tulki. Arī satura skripts var sazināties ar lapu, izmantojot standarta postMessage.

Web lapas konteksts

Šī ir pati tīmekļa lapa. Tam nav nekāda sakara ar paplašinājumu un tam nav piekļuves, izņemot gadījumus, kad šīs lapas domēns nav skaidri norādīts manifestā (vairāk par to tālāk).

Ziņapmaiņa

Dažādām lietojumprogrammas daļām ir jāapmainās ar ziņojumiem savā starpā. Šim nolūkam ir API runtime.sendMessage lai nosūtītu ziņu background и tabs.sendMessage lai nosūtītu ziņojumu uz lapu (satura skripts, uznirstošais logs vai tīmekļa lapa, ja tāda ir pieejama externally_connectable). Tālāk ir sniegts piemērs, kad piekļūstat 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))
    }
)

Pilnīgai saziņai varat izveidot savienojumus, izmantojot runtime.connect. Atbildi saņemsim runtime.Port, uz kuru, kamēr tas ir atvērts, varat nosūtīt neierobežotu skaitu ziņojumu. No klienta puses, piemēram, contentscript, tas izskatās šādi:

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

Serveris vai fons:

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

Ir arī pasākums onDisconnect un metode disconnect.

Pielietojuma diagramma

Izveidosim pārlūkprogrammas paplašinājumu, kas glabā privātās atslēgas, nodrošina piekļuvi publiskai informācijai (adrese, publiskā atslēga sazinās ar lapu un ļauj trešo pušu lietojumprogrammām pieprasīt parakstu darījumiem.

Lietojumprogrammu izstrāde

Mūsu lietojumprogrammai ir gan jāsadarbojas ar lietotāju, gan jānodrošina lapa ar API, lai izsauktu metodes (piemēram, lai parakstītu darījumus). Iztikt tikai ar vienu contentscript nedarbosies, jo tam ir piekļuve tikai DOM, bet ne lapas JS. Savienot, izmantojot runtime.connect mēs nevaram, jo ​​API ir nepieciešama visos domēnos, un manifestā var norādīt tikai konkrētus domēnus. Rezultātā diagramma izskatīsies šādi:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Būs vēl viens scenārijs - inpage, ko mēs ievadīsim lapā. Tas darbosies savā kontekstā un nodrošinās API darbam ar paplašinājumu.

sākums

Viss pārlūkprogrammas paplašinājuma kods ir pieejams vietnē GitHub. Apraksta laikā būs saites uz saistībām.

Sāksim ar manifestu:

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

Izveidojiet tukšu background.js, popup.js, inpage.js un contentscript.js. Mēs pievienojam popup.html - un mūsu lietojumprogrammu jau var ielādēt pārlūkprogrammā Google Chrome un pārliecināties, ka tā darbojas.

Lai to pārbaudītu, varat paņemt kodu tātad. Papildus tam, ko darījām, saite konfigurēja projekta montāžu, izmantojot tīmekļa pakotni. Lai pārlūkprogrammai pievienotu lietojumprogrammu, mapē chrome://extensions ir jāatlasa ielādēt unpacked un mape ar atbilstošo paplašinājumu - mūsu gadījumā dist.

Droša pārlūkprogrammas paplašinājuma rakstīšana

Tagad mūsu paplašinājums ir instalēts un darbojas. Varat palaist izstrādātāja rīkus dažādiem kontekstiem, kā norādīts tālāk.

uznirstošais logs ->

Droša pārlūkprogrammas paplašinājuma rakstīšana

Piekļuve satura skripta konsolei tiek nodrošināta, izmantojot tās lapas konsoli, kurā tā ir palaista.Droša pārlūkprogrammas paplašinājuma rakstīšana

Ziņapmaiņa

Tātad mums ir jāizveido divi saziņas kanāli: lapas <-> fons un uznirstošais <-> fons. Jūs, protams, varat vienkārši nosūtīt ziņojumus uz portu un izdomāt savu protokolu, bet es dodu priekšroku pieejai, ko redzēju metamaskas atvērtā pirmkoda projektā.

Šis ir pārlūkprogrammas paplašinājums darbam ar Ethereum tīklu. Tajā dažādas lietojumprogrammas daļas sazinās, izmantojot RPC, izmantojot dnode bibliotēku. Tas ļauj diezgan ātri un ērti organizēt apmaiņu, ja nodrošina to ar nodejs straumi kā transportu (tas nozīmē objektu, kas īsteno to pašu interfeisu):

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

Tagad mēs izveidosim lietojumprogrammu klasi. Tas izveidos API objektus uznirstošajam logam un tīmekļa lapai un izveidos tiem 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)
        })
    }
}

Šeit un tālāk globālā Chrome objekta vietā mēs izmantojam paplašinājumuApi, kas piekļūst pārlūkam Chrome Google pārlūkprogrammā un pārlūkprogrammai citos. Tas tiek darīts, lai nodrošinātu vairāku pārlūkprogrammu saderību, taču šī raksta vajadzībām varat vienkārši izmantot “chrome.runtime.connect”.

Izveidosim lietojumprogrammas gadījumu fona skriptā:

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

Tā kā dnode darbojas ar straumēm un mēs saņemam portu, ir nepieciešama adaptera klase. Tas ir izveidots, izmantojot lasāmās straumes bibliotēku, kas pārlūkprogrammā ievieš nodejs straumes:

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

Tagad izveidosim savienojumu lietotāja saskarnē:

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

Pēc tam satura skriptā izveidojam savienojumu:

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

Tā kā API mums ir nepieciešama nevis satura skriptā, bet tieši lapā, mēs veicam divas darbības:

  1. Mēs izveidojam divas plūsmas. Viens - uz lapu, ziņojuma augšpusē. Šim nolūkam mēs izmantojam šo šo paketi no metamaskas veidotājiem. Otrā straume ir fonā pār portu, kas saņemts no runtime.connect. Nopirksim tos. Tagad lapai būs straume fonā.
  2. Ievadiet skriptu DOM. Lejupielādējiet skriptu (piekļuve tam bija atļauta manifestā) un izveidojiet tagu script ar tā saturu iekšpusē:

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

Tagad mēs izveidojam api objektu lapā un iestatām to uz globālu:

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

Esam gatavi Remote Procedure Call (RPC) ar atsevišķu API lapai un lietotāja saskarnei. Savienojot jaunu lapu ar fonu, mēs varam redzēt šo:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Tukšs API un izcelsme. Lapas pusē mēs varam izsaukt funkciju hello šādi:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Darbs ar atzvanīšanas funkcijām mūsdienu JS ir sliktas manieres, tāpēc uzrakstīsim nelielu palīgu, lai izveidotu dnode, kas ļauj nodot API objektu utils.

API objekti tagad izskatīsies šādi:

export class SignerApp {

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

...

}

Objekta iegūšana no tālvadības pults, piemēram:

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

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

Un funkciju izsaukšana dod solījumu:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Pieejama versija ar asinhronām funkcijām šeit.

Kopumā RPC un straumes pieeja šķiet diezgan elastīga: mēs varam izmantot tvaika multipleksēšanu un izveidot vairākus dažādus API dažādiem uzdevumiem. Principā dnode var izmantot jebkur, galvenais ir transportu ietīt nodejs straumes formā.

Alternatīva ir JSON formāts, kas ievieš protokolu JSON RPC 2. Tomēr tas darbojas ar īpašiem transportiem (TCP un HTTP(S)), kas mūsu gadījumā nav piemērojams.

Iekšējais stāvoklis un lokālā krātuve

Mums būs jāsaglabā lietojumprogrammas iekšējais stāvoklis - vismaz parakstīšanas atslēgas. Mēs diezgan viegli varam pievienot lietojumprogrammai stāvokli un tā mainīšanas metodes uznirstošajā 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)
        }
    }

    ...

} 

Fonā mēs visu iesaiņosim funkcijā un ierakstīsim lietojumprogrammas objektu logā, lai mēs varētu ar to strādāt no konsoles:

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

Pievienosim dažus taustiņus no lietotāja interfeisa konsoles un redzēsim, kas notiek ar stāvokli:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Stāvokli vajag padarīt noturīgu, lai restartējot nepazaudētu atslēgas.

Mēs to saglabāsim lokālajā krātuvē, pārrakstot to ar katrām izmaiņām. Pēc tam piekļuve tai būs nepieciešama arī lietotāja saskarnei, un es arī vēlētos abonēt izmaiņas. Pamatojoties uz to, būs ērti izveidot novērojamu krātuvi un abonēt tās izmaiņas.

Mēs izmantosim mobx bibliotēku (https://github.com/mobxjs/mobx). Izvēle krita uz to, jo man nebija ar to jāstrādā, bet es ļoti gribēju to izpētīt.

Pievienosim sākotnējā stāvokļa inicializāciju un padarīsim veikalu novērojamu:

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

    ...

}

“Zem pārsega” mobx ir aizstājis visus veikala laukus ar starpniekserveri un pārtver visus zvanus uz tiem. Uz šīm ziņām būs iespējams abonēt.

Tālāk es bieži izmantošu terminu “mainot”, lai gan tas nav pilnīgi pareizi. Mobx izseko piekļuvi laukiem. Tiek izmantoti bibliotēkas izveidoto starpniekserveru objektu ieguvēji un iestatītāji.

Darbības dekoratori kalpo diviem mērķiem:

  1. Stingrā režīmā ar enforceActions karogu mobx aizliedz tieši mainīt stāvokli. Tiek uzskatīts par labu praksi strādāt stingros apstākļos.
  2. Pat ja funkcija maina stāvokli vairākas reizes - piemēram, mēs mainām vairākus laukus vairākās koda rindās - novērotāji tiek informēti tikai tad, kad tā ir pabeigta. Tas ir īpaši svarīgi priekšgalam, kur nevajadzīgi stāvokļa atjauninājumi noved pie nevajadzīgas elementu renderēšanas. Mūsu gadījumā ne pirmais, ne otrais nav īpaši aktuāls, taču mēs ievērosim labāko praksi. Visām funkcijām, kas maina novērojamo lauku stāvokli, ir ierasts pievienot dekoratorus.

Fonā mēs pievienosim inicializēšanu un stāvokļa saglabāšanu vietējā krātuvē:

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

Šeit interesanta ir reakcijas funkcija. Tam ir divi argumenti:

  1. Datu atlasītājs.
  2. Apdarinātājs, kas tiks izsaukts ar šiem datiem katru reizi, kad tie mainās.

Atšķirībā no redux, kur mēs skaidri saņemam stāvokli kā argumentu, mobx atceras, kuriem novērojumiem mēs piekļūstam atlasītājā, un izsauc apstrādātāju tikai tad, kad tie mainās.

Ir svarīgi precīzi saprast, kā mobx izlemj, kurus novērojamos datus mēs abonējam. Ja es uzrakstītu atlasītāju šādā kodā() => app.store, tad reakcija nekad netiks izsaukta, jo pati krātuve nav novērojama, ir tikai tās lauki.

Ja es to uzrakstītu šādi () => app.store.keys, tad atkal nekas nenotiktu, jo, pievienojot/noņemot masīva elementus, atsauce uz to nemainīsies.

Mobx pirmo reizi darbojas kā atlasītājs un seko tikai tiem novērojamajiem objektiem, kuriem esam piekļuvuši. Tas tiek darīts, izmantojot starpniekservera ieguvējus. Tāpēc šeit tiek izmantota iebūvētā funkcija toJS. Tas atgriež jaunu objektu ar visiem starpniekserveriem, kas aizstāti ar sākotnējiem laukiem. Izpildes laikā tas nolasa visus objekta laukus - tādējādi tiek aktivizēti getteri.

Uznirstošajā konsolē mēs atkal pievienosim vairākus taustiņus. Šoreiz tie nokļuva arī localStorage:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Kad fona lapa tiek atkārtoti ielādēta, informācija paliek vietā.

Visu lietojumprogrammas kodu līdz šim brīdim var apskatīt šeit.

Droša privāto atslēgu glabāšana

Privāto atslēgu glabāšana skaidrā tekstā nav droša: vienmēr pastāv iespēja, ka jūs tiksit uzlauzts, jūs iegūsit piekļuvi datoram utt. Tāpēc lokālajā krātuvē mēs saglabāsim atslēgas ar paroli šifrētā formā.

Lielākai drošībai aplikācijai pievienosim bloķētu stāvokli, kurā atslēgām nebūs piekļuves vispār. Mēs automātiski pārsūtīsim paplašinājumu uz bloķēšanas stāvokli taimauta dēļ.

Mobx ļauj saglabāt tikai minimālu datu kopu, un pārējais tiek automātiski aprēķināts, pamatojoties uz to. Tās ir tā sauktās aprēķinātās īpašības. Tos var salīdzināt ar skatiem datu bāzēs:

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

Tagad mēs glabājam tikai šifrētās atslēgas un paroli. Viss pārējais ir aprēķināts. Mēs veicam pārsūtīšanu uz bloķētu stāvokli, noņemot paroli no stāvokļa. Publiskajā API tagad ir krātuves inicializācijas metode.

Rakstīts šifrēšanai utilītas, izmantojot kripto-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)
}

Pārlūkprogrammai ir dīkstāves API, caur kuru varat abonēt notikumu - stāvokļa izmaiņas. Valsts, attiecīgi, var būt idle, active и locked. Dīkstāvei varat iestatīt taimautu, un bloķēts tiek iestatīts, kad pati OS ir bloķēta. Mēs arī mainīsim atlasītāju saglabāšanai vietējā krātuvē:

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

Kods pirms šīs darbības ir šeit.

Darījumi

Tātad, mēs nonākam pie vissvarīgākās lietas: darījumu izveide un parakstīšana blokķēdē. Mēs izmantosim WAVES blokķēdi un bibliotēku viļņi-transakcijas.

Vispirms pievienosim stāvoklim ziņojumu masīvu, kas jāparaksta, pēc tam pievienosim metodes jauna ziņojuma pievienošanai, paraksta apstiprināšanai un atteikšanai:

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

    ...
}

Kad mēs saņemam jaunu ziņojumu, mēs pievienojam tam metadatus, dariet observable un pievienot store.messages.

Ja nē observable manuāli, tad mobx to darīs pats, pievienojot ziņojumus masīvam. Tomēr tas radīs jaunu objektu, uz kuru mums nebūs atsauces, bet tas mums būs vajadzīgs nākamajam solim.

Tālāk mēs atgriežam solījumu, kas tiek izpildīts, mainoties ziņojuma statusam. Statusu uzrauga reakcija, kas, mainoties statusam, "nogalinās pati sevi".

Metodes kods approve и reject ļoti vienkārši: mēs vienkārši mainām ziņojuma statusu pēc tā parakstīšanas, ja nepieciešams.

Mēs ievietojām Apstiprināt un noraidīt UI API, bet newMessage lapas API:

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

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

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

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

    ...
}

Tagad mēģināsim parakstīt darījumu ar paplašinājumu:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Kopumā viss ir gatavs, atliek tikai pievienojiet vienkāršu lietotāja interfeisu.

UI

Interfeisam ir nepieciešama piekļuve lietojumprogrammas stāvoklim. UI pusē mēs to darīsim observable stāvokli un pievienojiet API funkciju, kas mainīs šo stāvokli. Papildināsim observable uz API objektu, kas saņemts no fona:

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

Beigās mēs sākam renderēt lietojumprogrammas saskarni. Šī ir reakcijas programma. Fona objekts tiek vienkārši nodots, izmantojot rekvizītus. Protams, būtu pareizi izveidot atsevišķu pakalpojumu metodēm un veikalu valstij, bet šī raksta vajadzībām ar to pietiek:

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

Ar mobx ir ļoti viegli sākt renderēšanu, kad dati mainās. Mēs vienkārši piekarinām novērotāja dekoratoru no iepakojuma mobx-reaģēt komponentā, un renderēšana tiks automātiski izsaukta, kad mainās kādi novērojamie elementi, uz kuriem atsaucas komponents. Jums nav nepieciešams neviens mapStateToProps vai izveidot savienojumu, piemēram, redux. Viss darbojas uzreiz no kastes:

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

Pārējās sastāvdaļas var apskatīt kodā UI mapē.

Tagad lietojumprogrammu klasē jums ir jāizveido lietotāja interfeisa stāvokļa atlasītājs un jāpaziņo lietotāja interfeisam, kad tas mainās. Lai to izdarītu, pievienosim metodi getState и reactionzvanot 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())

        })
    }

    ...
}

Saņemot objektu remote ir izveidots reaction lai mainītu stāvokli, kas izsauc funkciju UI pusē.

Pēdējais pieskāriens ir jaunu ziņojumu parādīšanas pievienošana paplašinājuma ikonai:

function setupApp() {
...

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

...
}

Tātad pieteikums ir gatavs. Tīmekļa lapas var pieprasīt parakstu darījumiem:

Droša pārlūkprogrammas paplašinājuma rakstīšana

Droša pārlūkprogrammas paplašinājuma rakstīšana

Kods ir pieejams šeit saite.

Secinājums

Ja esat izlasījis rakstu līdz beigām, bet joprojām ir jautājumi, varat tos uzdot vietnē krātuves ar paplašinājumu. Tur jūs atradīsiet arī saistības katram norādītajam solim.

Un, ja vēlaties apskatīt faktiskā paplašinājuma kodu, varat to atrast šeit.

Kods, repozitorijs un darba apraksts no siemarell

Avots: www.habr.com

Pievieno komentāru