Escrevendo uma extensão segura do navegador


Ao contrário da arquitetura comum "cliente-servidor", aplicativos descentralizados são caracterizados por:


  • Não há necessidade de armazenar um banco de dados com logins e senhas de usuários. As informações de acesso são armazenadas exclusivamente pelos próprios usuários e a confirmação de sua autenticidade ocorre no nível do protocolo.
  • Não há necessidade de usar um servidor. A lógica do aplicativo pode ser executada em uma rede blockchain, onde é possível armazenar a quantidade necessária de dados.

Existem 2 repositórios relativamente seguros para chaves de usuário - carteiras de hardware e extensões de navegador. A maioria das carteiras de hardware é o mais segura possível, mas difícil de usar e longe de ser gratuita, mas as extensões de navegador são a combinação perfeita de segurança e facilidade de uso e também podem ser totalmente gratuitas para os usuários finais.


Por tudo isso, queríamos fazer a extensão mais segura, o que simplifica o desenvolvimento de aplicativos descentralizados, fornecendo uma API simples para trabalhar com transações e assinaturas.
Falaremos sobre essa experiência abaixo.


O artigo fornecerá instruções passo a passo sobre como escrever uma extensão do navegador, com exemplos de código e capturas de tela. Você pode encontrar todo o código no repositório . Cada confirmação corresponde logicamente a uma seção deste artigo.


Uma Breve História das Extensões de Navegador


As extensões do navegador já existem há algum tempo. No Internet Explorer, eles apareceram em 1999, no Firefox - em 2004. No entanto, por muito tempo, não houve um padrão único para extensões.


Podemos dizer que ele apareceu junto com as extensões na quarta versão do Google Chrome. É claro que não havia especificação na época, mas foi a API do Chrome que se tornou sua base: tendo conquistado grande parte do mercado de navegadores e tendo uma loja de aplicativos integrada, o Chrome realmente definiu o padrão para extensões de navegador.


A Mozilla tinha seu próprio padrão, mas, vendo a popularidade das extensões para o Chrome, a empresa decidiu criar uma API compatível. Em 2015, por iniciativa da Mozilla, um grupo especial foi criado dentro do World Wide Web Consortium (W3C) para trabalhar nas especificações para extensões entre navegadores.


Com base nas extensões de API já existentes para o Chrome. O trabalho foi suportado pela Microsoft (o Google se recusou a participar do desenvolvimento do padrão) e, como resultado, apareceu um rascunho da especificação .


Formalmente, a especificação é suportada pelo Edge, Firefox e Opera (observe que o Chrome não está nesta lista). Mas, de fato, o padrão é amplamente compatível com o Chrome, uma vez que é realmente escrito com base em suas extensões. Leia mais sobre a API WebExtensions aqui .


Estrutura de extensão


O único arquivo necessário para a extensão é o manifesto (manifest.json). Ele é o "ponto de entrada" para a extensão.


Manifesto


Por especificação, o arquivo de manifesto é um arquivo JSON válido. Uma descrição completa das chaves do manifesto com informações sobre quais chaves são suportadas em qual navegador pode ser encontrado aqui .


As chaves que não estão na especificação "podem ser" ignoradas (o Chrome e o Firefox relatam erros, mas as extensões continuam funcionando).


E gostaria de chamar a atenção para alguns pontos.


  1. background - um objeto que inclui os seguintes campos:
    1. scripts - uma matriz de scripts que serão executados no contexto de segundo plano (falaremos sobre isso um pouco mais adiante);
    2. página - em vez de scripts que serão executados em uma página em branco, você pode especificar html com conteúdo. Nesse caso, o campo de script será ignorado e os scripts precisarão ser inseridos na página com o conteúdo;
    3. persistente - um sinalizador binário, se não for especificado, o navegador “interromperá” o processo em segundo plano quando considerar que não está fazendo nada e será reiniciado, se necessário. Caso contrário, a página será descarregada apenas quando o navegador estiver fechado. Não suportado no Firefox.
  2. content_scripts - uma matriz de objetos que permite carregar scripts diferentes em diferentes páginas da web. Cada objeto contém os seguintes campos importantes:
    1. correspondências - padrão de URL pelo qual é determinado se um script de conteúdo específico será incluído ou não.
    2. js - uma lista de scripts que serão carregados nesta partida;
    3. exclude_matches - exclui URLs de match campo de correspondência que correspondem a esse campo.
  3. page_action - na verdade, é o objeto responsável pelo ícone que aparece ao lado da barra de endereço no navegador e pela interação com ele. Também permite mostrar a janela pop-up, que é definida usando HTML, CSS e JS.
    1. default_popup - caminho para o arquivo HTML com uma interface pop-up, pode conter CSS e JS.
  4. permissões - uma matriz para gerenciar direitos de extensão. Existem três tipos de direitos que são descritos em detalhes aqui.
  5. web_accessible_resources - recursos de extensão que uma página da web pode solicitar, por exemplo, imagens, JS, CSS, arquivos HTML.
  6. externalally_connectable - aqui você pode especificar explicitamente os IDs de outras extensões e os domínios das páginas da Web nas quais você pode se conectar. Um domínio pode ser um segundo nível ou superior. Não funciona no Firefox.

Contexto de execução


A extensão possui três contextos de execução de código, ou seja, o aplicativo consiste em três partes com diferentes níveis de acesso à API do navegador.


Contexto da extensão


A maioria das APIs está disponível aqui. Nesse contexto, "ao vivo":


  1. Página de fundo - parte de "back-end" da extensão. O arquivo é indicado no manifesto pela tecla "background".
  2. Página pop-up - página pop-up que aparece quando você clica no ícone de extensão. No manifesto, browser_action -> browser_action - browser_action default_popup .
  3. Página personalizada - página de extensão, "vivendo" em uma guia separada no formato chrome-extension://<id_>/customPage.html .

Esse contexto existe independentemente das janelas e guias do navegador. A página de segundo plano existe em uma única cópia e sempre funciona (a exceção é a página de evento, quando o script de segundo plano é iniciado em um evento e morre após sua execução). A página pop-up existe quando a janela pop-up é aberta e a página Personalizada - enquanto a guia com ela está aberta. Não há acesso a outras guias e seu conteúdo nesse contexto.


Contexto do script de conteúdo


O arquivo de script de conteúdo é iniciado junto com cada guia do navegador. Ele tem acesso a parte da API de extensão e à árvore DOM da página da web. Os scripts de conteúdo são responsáveis ​​por interagir com a página. As extensões que manipulam a árvore DOM fazem isso nos scripts de conteúdo - por exemplo, bloqueadores de anúncios ou tradutores. Além disso, o script de conteúdo pode se comunicar com a página por meio do postMessage padrão.


Contexto da página da Web


Esta é realmente a própria página da web. Não tem nada a ver com a extensão e não tem acesso a ela, a menos que o domínio desta página não esteja especificado explicitamente no manifesto (mais sobre isso abaixo).


Mensagens


Diferentes partes do aplicativo devem trocar mensagens entre si. Para fazer isso, existe uma API runtime.sendMessage para enviar uma mensagem em background e tabs.sendMessage para enviar uma mensagem para uma página (script de conteúdo, pop-up ou página da Web, se externally_connectable presente). A seguir, é apresentado um exemplo ao acessar a API do 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)) } ) 

Para comunicação completa, você pode criar conexões através do runtime.connect . Em resposta, obtemos o runtime.Port , no qual, enquanto está aberto, você pode enviar qualquer número de mensagens. No lado do cliente, por exemplo, contentscript , fica assim:


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

Servidor ou plano de fundo:


 //    '' .  , 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) { ... }); 

Há também um evento onDisconnect e um método de disconnect .


Esboço da aplicação


Vamos criar uma extensão do navegador que armazene chaves privadas, forneça acesso a informações públicas (endereço, chave pública se comunica com a página e permite que aplicativos de terceiros solicitem uma assinatura de transação.


Desenvolvimento de aplicações


Nosso aplicativo deve interagir com o usuário e fornecer uma página de API para chamar métodos (por exemplo, para assinar transações). Não funcionará apenas com o contentscript , pois ele tem acesso apenas ao DOM, mas não à página JS. Não é possível conectar-se por meio do runtime.connect , porque a API é necessária em todos os domínios e somente domínios específicos podem ser especificados no manifesto. Como resultado, o esquema ficará assim:



Haverá outro script - inpage , que injetaremos na página. Ele será executado em seu contexto e fornecerá uma API para trabalhar com a extensão.


Iniciar


Todo o código de extensão do navegador está disponível no GitHub . No processo de descrição, haverá links para confirmações.


Vamos começar com o 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"] } 

Crie background.js, popup.js, inpage.js e contentscript.js vazios. Adicione popup.html - e nosso aplicativo já pode ser baixado no Google Chrome e verifique se ele funciona.


Para verificar isso, você pode pegar o código aqui . Além do que fizemos, o link está configurado para criar o projeto usando o webpack. Para adicionar um aplicativo ao navegador, no chrome: // extensões você precisa selecionar load unpacked e a pasta com a extensão correspondente - no nosso caso, dist.



Agora nossa extensão está instalada e funcionando. Você pode executar ferramentas de desenvolvedor para diferentes contextos da seguinte maneira:


pop-up ->



O acesso ao console do script de conteúdo é realizado através do console da própria página em que é iniciado.


Mensagens


Portanto, precisamos estabelecer dois canais de comunicação: inpage <-> background e popup <-> background. É claro que você pode simplesmente enviar mensagens para a porta e inventar seu protocolo, mas prefiro a abordagem que espiei no projeto de meta-máscara de código aberto.


Esta é uma extensão do navegador para trabalhar com a rede Ethereum. Nele, diferentes partes do aplicativo se comunicam através do RPC usando a biblioteca dnode. Ele permite que você organize de forma rápida e conveniente uma troca se você fornecer o nodejs stream como um transporte (ou seja, um objeto que implementa a mesma interface):


 import Dnode from "dnode/browser"; //           ,         // C // API,     const dnode = Dnode({ hello: (cb) => cb(null, "world") }) // ,     dnode.  nodejs .     'readable-stream' connectionStream.pipe(dnode).pipe(connectionStream) //  const dnodeClient = Dnode() //         API    //    world dnodeClient.once('remote', remote => { remote.hello(((err, value) => console.log(value))) }) 

Agora vamos criar uma classe de aplicativo. Ele criará objetos de API para pop-up e página da web e também criará dnode para eles:


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

A seguir, em vez do objeto global do Chrome, usamos extentionApi, que se refere ao Chrome no navegador do Google e ao navegador em outros. Isso é feito para compatibilidade entre navegadores, mas simplesmente chrome.runtime.connect pode ser usado na estrutura deste artigo.


Crie a instância do aplicativo no script em segundo plano:


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

Como o dnode trabalha com fluxos e obtemos a porta, é necessária uma classe de adaptador. É feito usando a biblioteca de fluxo legível, que implementa os fluxos nodejs no navegador:


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

Agora crie uma conexão na interface do usuário:


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

Em seguida, criamos uma conexão no script de conteúdo:


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

Como não precisamos da API no script de conteúdo, mas diretamente na página, fazemos duas coisas:


  1. Criamos dois fluxos. Um deles é voltado para a página, em cima do postMessage. Para isso, usamos este pacote dos criadores da metamask. O segundo fluxo é para segundo plano na parte superior da porta recebida do runtime.connect . Pip-los. Agora a página terá um fluxo em segundo plano.
  2. Injete o script no DOM. Distribuímos o script (o acesso era permitido no manifesto) e criamos uma tag de script com seu conteúdo:

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

Agora crie um objeto api na página e inicie-o global:


 import PostMessageStream from 'post-message-stream'; import Dnode from 'dnode/browser'; setupInpageApi().catch(console.error); async function setupInpageApi() { //    const connectionStream = new PostMessageStream({ name: 'page', target: 'content', }); const dnode = Dnode(); connectionStream.pipe(dnode).pipe(connectionStream); //   API const pageApi = await new Promise(resolve => { dnode.once('remote', api => { resolve(api) }) }); //   window global.SignerApp = pageApi; } 

Estamos prontos para a RPC (Chamada de procedimento remoto) com uma API separada para a página e a interface do usuário . Ao conectar uma nova página ao plano de fundo, podemos ver o seguinte:



API e origem vazias. No lado da página, podemos chamar a função hello como esta:



Trabalhar com funções de retorno de chamada no JS moderno é uma péssima idéia, portanto, escreveremos um pequeno auxiliar para criar um dnode que permita que você passe APIs para utilitários em um objeto.


Os objetos da API agora ficarão assim:


 export class SignerApp { popupApi() { return { hello: async () => "world" } } ... } 

Obtendo um objeto do controle remoto da seguinte maneira:


 import {cbToPromise, transformMethods} from "../../src/utils/setupDnode"; const pageApi = await new Promise(resolve => { dnode.once('remote', remoteApi => { //      callback  promise resolve(transformMethods(cbToPromise, remoteApi)) }) }); 

Uma chamada de função retorna uma promessa:



Uma versão com funções assíncronas está disponível aqui .


Em geral, a abordagem com RPC e fluxos parece bastante flexível: podemos usar a multiplexação a vapor e criar várias APIs diferentes para tarefas diferentes. Em princípio, o dnode pode ser usado em qualquer lugar, o principal é agrupar o transporte na forma de um fluxo nodejs.


Uma alternativa é o formato JSON, que implementa o protocolo JSON RPC 2. No entanto, ele funciona com transportes específicos (TCP e HTTP (S)), o que não é aplicável em nosso caso.


Estado interno e localStorage


Precisamos armazenar o estado interno do aplicativo - pelo menos, chaves para assinatura. Podemos adicionar facilmente o estado ao aplicativo e aos métodos para alterá-lo na API pop-up:


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

Em segundo plano, agruparemos tudo em uma função e gravamos o objeto do aplicativo na janela para que você possa trabalhar com ele no console:


 import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {SignerApp} from "./SignerApp"; const DEV_MODE = process.env.NODE_ENV !== 'production'; setupApp(); function setupApp() { const app = new SignerApp(); if (DEV_MODE) { global.app = app; } extensionApi.runtime.onConnect.addListener(connectRemote); function connectRemote(remotePort) { const processName = remotePort.name; const portStream = new PortStream(remotePort); if (processName === 'contentscript') { const origin = remotePort.sender.url; app.connectPage(portStream, origin) } else { app.connectPopup(portStream) } } } 

Adicione algumas chaves do console da interface do usuário e veja o que aconteceu com o estado:



O estado deve ser persistente para que, quando você reinicie, as chaves não sejam perdidas.


Vamos armazená-lo no localStorage, substituindo a cada alteração. Posteriormente, o acesso a ele também será necessário para a interface do usuário e eu também quero assinar as alterações. Com base nisso, será conveniente fazer armazenamento observável e assinar suas alterações.


Usaremos a biblioteca mobx ( https://github.com/mobxjs/mobx ). A escolha recaiu sobre ela, já que não precisava trabalhar com ela, mas queria muito estudá-la.


Adicione a inicialização do estado inicial e torne a loja observável:


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

O mobx "under the hood" substituiu todos os campos da loja por proxy e intercepta todas as chamadas para eles. Você pode assinar esses apelos.


Além disso, usarei frequentemente o termo "mediante alteração", embora isso não esteja totalmente correto. Mobx rastreia o acesso aos campos. Os getters e setters de objetos proxy criados pela biblioteca são usados.


Os decoradores de ação têm dois propósitos:


  1. No modo estrito com a bandeira enforceActions, o mobx proíbe alterar o estado diretamente. É considerado uma boa prática trabalhar no modo estrito.
  2. Mesmo que a função mude o estado várias vezes - por exemplo, alteramos vários campos para várias linhas de código - os observadores são notificados apenas quando concluídos. Isso é especialmente importante para o frontend, onde atualizações desnecessárias de estado levam à renderização desnecessária de elementos. No nosso caso, nem o primeiro nem o segundo são particularmente relevantes; no entanto, seguiremos as melhores práticas. Os decoradores decidiram manter todas as funções que alteram o estado dos campos observados.

Em segundo plano, adicione a inicialização e salve o estado em localStorage:


 import {reaction, toJS} from 'mobx'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {SignerApp} from "./SignerApp"; //  . /  / localStorage  JSON    'store' import {loadState, saveState} from "./utils/localStorage"; const DEV_MODE = process.env.NODE_ENV !== 'production'; setupApp(); function setupApp() { const initState = loadState(); const app = new SignerApp(initState); if (DEV_MODE) { global.app = app; } // Setup state persistence //  reaction  ,     .    ,    const localStorageReaction = reaction( () => toJS(app.store), // -  saveState // ,      ,    ); extensionApi.runtime.onConnect.addListener(connectRemote); function connectRemote(remotePort) { const processName = remotePort.name; const portStream = new PortStream(remotePort); if (processName === 'contentscript') { const origin = remotePort.sender.url app.connectPage(portStream, origin) } else { app.connectPopup(portStream) } } } 

A função de reação é interessante aqui. Ela tem dois argumentos:


  1. Seletor de dados.
  2. Um manipulador que será chamado com esses dados toda vez que mudar.

Ao contrário do redux, onde obtemos explicitamente o estado como argumento, o mobx lembra a qual observável nos referimos dentro do seletor, e somente quando alterá-los chama o manipulador.


É importante entender exatamente como o mobx decide em qual observável estamos assinando. Se eu escrevi um seletor no código como este () => app.store , a reação nunca será chamada, já que o repositório em si não é observável, apenas seus campos são assim.


Se eu escrevesse assim () => app.store.keys , nada aconteceria novamente, pois ao adicionar / remover elementos da matriz, o link para ela não será alterado.


Pela primeira vez, o Mobx executa a função de um seletor e monitora apenas aqueles observáveis ​​aos quais temos acesso. Isso é feito por meio de getters de proxy. toJS . , . – , .


popup . localStorage:



background- .


.



: , , . localStorage .


locked, . locked .


Mobx , . — computed properties. view :


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

. . locked . API .


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

idle API, — . , , idle , active locked . idle , locked , . localStorage:


 import {reaction, toJS} from 'mobx'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {SignerApp} from "./SignerApp"; import {loadState, saveState} from "./utils/localStorage"; const DEV_MODE = process.env.NODE_ENV !== 'production'; const IDLE_INTERVAL = 30; setupApp(); function setupApp() { const initState = loadState(); const app = new SignerApp(initState); if (DEV_MODE) { global.app = app; } //     ,    , reaction   reaction( () => ({ vault: app.store.vault }), saveState ); //  ,    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL); //             extensionApi.idle.onStateChanged.addListener(state => { if (['locked', 'idle'].indexOf(state) > -1) { app.lock() } }); // Connect to other contexts extensionApi.runtime.onConnect.addListener(connectRemote); function connectRemote(remotePort) { const processName = remotePort.name; const portStream = new PortStream(remotePort); if (processName === 'contentscript') { const origin = remotePort.sender.url app.connectPage(portStream, origin) } else { app.connectPopup(portStream) } } } 

.


Transações


, : . WAVES waves-transactions .


, , — , :


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

, observable store.messages .


observable , mobx messages. , , .


, . reaction, "" .


approve reject : , , .


Approve reject API UI, newMessage — 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) } } ... } 

:



, UI .


UI


. UI observable API , . observable API, background:


 import {observable} from 'mobx' import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode"; import {initApp} from "./ui/index"; const DEV_MODE = process.env.NODE_ENV !== 'production'; setupUi().catch(console.error); async function setupUi() { //   ,     const backgroundPort = extensionApi.runtime.connect({name: 'popup'}); const connectionStream = new PortStream(backgroundPort); //   observable   background'a let backgroundState = observable.object({}); const api = { //  ,    observable updateState: async state => { Object.assign(backgroundState, state) } }; //  RPC  const dnode = setupDnode(connectionStream, api); const background = await new Promise(resolve => { dnode.once('remote', remoteApi => { resolve(transformMethods(cbToPromise, remoteApi)) }) }); //   background observable   background.state = backgroundState; if (DEV_MODE) { global.background = background; } //   await initApp(background) } 

. react-. Background- props. , , store , :


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

mobx . observer mobx-react , observable, . mapStateToProps connect, redux. " ":


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

UI .


UI UI. getState reaction , 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()) }) } ... } 

remote reaction , UI.


— :


 function setupApp() { ... // Reaction    . reaction( () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '', text => extensionApi.browserAction.setBadgeText({text}), {fireImmediately: true} ); ... } 

, . - :




.


Conclusão


, , . .


, .


, siemarell

Source: https://habr.com/ru/post/pt451796/


All Articles