
Ao contrário da arquitetura "cliente-servidor" comum, aplicações descentralizadas caracterizam-se por:
- A ausência da necessidade de armazenar um banco de dados com os logins e senhas dos usuários. As informações de acesso são mantidas exclusivamente pelos usuários, e a validação de sua veracidade ocorre a nível de protocolo.
- A ausência da necessidade de usar um servidor. A lógica da aplicação pode ser executada na rede blockchain, onde também é possível armazenar a quantidade necessária de dados.
Existem 2 repositórios relativamente seguros para chaves de usuários — carteiras de hardware e extensões de navegador. As carteiras de hardware são, na sua maioria, extremamente seguras, mas difíceis de usar e não são gratuitas, enquanto as extensões de navegador são uma combinação ideal de segurança e facilidade de uso, e podem ser completamente gratuitas para os usuários finais.
Considerando tudo isso, decidimos criar uma extensão que é o mais segura possível, simplificando o desenvolvimento de aplicações descentralizadas, fornecendo uma API simples para trabalhar com transações e assinaturas.
Sobre essa experiência, falaremos mais adiante.
O artigo incluirá um guia passo a passo sobre como escrever uma extensão de navegador, com exemplos de código e capturas de tela. Todo o código pode ser encontrado em . Cada commit corresponde logicamente a uma seção deste artigo.
Uma breve história das extensões de navegador
As extensões de navegador existem há bastante tempo. No Internet Explorer, surgiram em 1999, e no Firefox, em 2004. No entanto, durante muito tempo não houve um padrão unificado para extensões.
Pode-se dizer que esse padrão surgiu junto com as extensões na quarta versão do Google Chrome. Claro que não havia especificações na época, mas a API do Chrome tornou-se sua base: conquistando a maior parte do mercado de navegadores e com uma loja de aplicativos integrada, o Chrome efetivamente definiu o padrão para extensões de navegador.
A Mozilla tinha seu próprio padrão, mas, ao ver a popularidade das extensões para Chrome, decidiu fazer uma API compatível. Em 2015, por iniciativa da Mozilla, foi formada, no âmbito do World Wide Web Consortium (W3C), um grupo especial para trabalhar nas especificações de extensões cross-browser.
A base foi o API existente de extensões para Chrome. O trabalho foi realizado com o apoio da Microsoft (a Google se recusou a participar do desenvolvimento do padrão), e como resultado, um rascunho foi criado. .
Formalmente, a especificação é suportada pelo Edge, Firefox e Opera (note que o Chrome está ausente desta lista). Mas, na verdade, o padrão é em grande parte compatível com o Chrome, pois foi efetivamente escrito com base em suas extensões. Mais informações sobre a API WebExtensions podem ser lidas. .
Estrutura da extensão
O único arquivo que é absolutamente necessário para a extensão é o manifesto (manifest.json). Ele também é o "ponto de entrada" na extensão.
Manifesto
De acordo com a especificação, o arquivo do 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 vista. .
As chaves que não estão na especificação "podem" ser ignoradas (tanto o Chrome quanto o Firefox relatam erros, mas as extensões continuam funcionando).
Gostaria de chamar a atenção para alguns pontos.
- background — objeto que inclui os seguintes campos:
- scripts — array de scripts que serão executados no contexto de background (falaremos sobre isso um pouco mais tarde);
- page — em vez de scripts que serão executados em uma página vazia, é possível definir HTML com conteúdo. Nesse caso, o campo script será ignorado, e os scripts precisarão ser inseridos na página com conteúdo;
- persistente — um flag binário, se não for especificado, o navegador "matará" o processo de background quando considerar que não está fazendo nada, e reiniciará quando necessário. Caso contrário, a página será descarregada apenas ao fechar o navegador. Não é suportado no Firefox.
- content_scripts — array de objetos, permitindo carregar diferentes scripts em diferentes páginas da web. Cada objeto contém os seguintes campos importantes:
- matches — , que determina se um determinado script de conteúdo será incluído ou não.
- js — lista de scripts que serão carregados na correspondência atual;
- exclude_matches — exclui do campo
matchURLs que atendem a este campo.
- page_action — é, na verdade, um objeto que responde pelo ícone que é exibido ao lado da barra de endereços do navegador e sua interação. Também permite mostrar uma janela popup, que é definida através de seu próprio HTML, CSS e JS.
- default_popup — caminho para o arquivo HTML com a interface popup, pode conter CSS e JS.
- permissions — array for managing extension permissions. There are 3 types of permissions, which are described in detail.
- web_accessible_resources — resources of the extension that can be requested by a web page, such as images, JS files, CSS, HTML.
- externally_connectable — this is where you can explicitly specify the IDs of other extensions and the domains of the web pages that can connect. The domain can be a second-level domain or higher. It does not work in Firefox.
Execution context
The extension has three code execution contexts, meaning it consists of three parts with different levels of access to the browser API.
Extension context
Most of the API is available here. In this context, the following 'live':
- Background page — the 'backend' part of the extension. The file is specified in the manifest by the key 'background'.
- Popup page — the popup page that appears when you click on the extension icon. In the manifest,
browser_action->default_popup. - Custom page — the extension page, 'living' in a separate tab like
chrome-extension:///customPage.html.
This context exists independently of browser windows and tabs. Background page exists as a single instance and works all the time (except for the event page, when the background script runs on an event and 'dies' after execution). Popup page exists when a popup window is open, while Custom page — a tab with it is open. There is no access to other tabs and their content from this context.
Content script context
The content script file runs with each browser tab. It has access to part of the extension API and the DOM tree of the web page. Content scripts are responsible for interacting with the page. Extensions that manipulate the DOM tree do this in content scripts – for example, ad blockers or translators. The content script can also communicate with the page through standard postMessage.
Web page context
This is actually the web page itself. It has no relation to the extension and does not have access to it, except in cases where the domain of this page is explicitly specified in the manifest (more on this below).
Message exchange
Different parts of the application must exchange messages with each other. For this, there is the API runtime.sendMessage to send a message background e tabs.sendMessage to send a message to the page (content script, popup, or web page if available externally_connectable). Below is an example of how to access the 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))
}
)Para comunicação completa, é possível criar conexões através de runtime.connect. Em resposta, receberemos runtime.Port, no qual, enquanto estiver aberto, podemos enviar qualquer quantidade de mensagens. Do lado do cliente, por exemplo, contentscript, isso se parece com isto:
// Опять же 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 background:
// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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) {
...
});Também existe o evento onDisconnect e o método desconexão.
Esquema do aplicativo
Vamos criar uma extensão de navegador que armazena chaves privadas, fornece acesso a informações públicas (endereço, chave pública se comunica com a página e permite que aplicativos externos solicitem a assinatura de transações.
Desenvolvendo um aplicativo
Nosso aplicativo deve interagir com o usuário e fornecer à página uma API para chamar métodos (por exemplo, para assinar transações). Não podemos contar apenas com contentscript porque ele tem acesso apenas ao DOM, mas não ao JS da página. Não podemos nos conectar através de runtime.connect porque a API é necessária em todos os domínios, e no manifesto podemos especificar apenas alguns específicos. No final, o esquema ficará assim:

Haverá um outro script — inpage, que vamos injetar na página. Ele será executado em seu contexto e fornecerá uma API para trabalhar com a extensão.
Início
Todo o código da extensão do navegador está disponível em . Durante a descrição, haverá links para os commits.
Vamos começar com o manifesto:
{
// Nome e descrição, versão. Tudo isso será visível no navegador em chrome://extensions/?id=<id da extensão>
"name": "Signer",
"description": "Demonstração da extensão",
"version": "0.0.1",
"manifest_version": 2,
// Scripts que serão executados em background, pode haver vários
"background": {
"scripts": ["background.js"]
},
// Qual html usar para o popup
"browser_action": {
"default_title": "Minha Extensão",
"default_popup": "popup.html"
},
// Scripts de conteúdo.
// Temos um objeto: para todos os url que começam com http ou https, executamos
// context do contentscript com o script contentscript.js. Executa imediatamente após receber o documento para todos os frames
"content_scripts": [
{
"matches": [
"http:/*/*",
"https:/*/*"
],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
// Acesso permitido ao localStorage e idle api
"permissions": [
"storage",
// "unlimitedStorage",
//"clipboardWrite",
"idle"
//"activeTab",
//"webRequest",
//"notifications",
//"tabs"
],
// Aqui são especificados os recursos a que a página web terá acesso. Ou seja, eles podem ser solicitados através de fetch ou simplesmente xhr
"web_accessible_resources": ["inpage.js"]
}Criamos arquivos vazios background.js, popup.js, inpage.js e contentscript.js. Adicionamos popup.html — e já podemos carregar nosso aplicativo no Google Chrome e verificar se ele está funcionando.
Para verificar isso, podemos pegar o código . Além do que fizemos, o link está configurado para a construção do projeto com webpack. Para adicionar o aplicativo ao navegador, em chrome://extensions, é necessário escolher carregar empacotado e a pasta com a extensão correspondente — no nosso caso, dist.

Agora, nossa extensão está instalada e funcionando. Para abrir as ferramentas de desenvolvedor em diferentes contextos, pode-se fazer da seguinte forma:
popup ->

O acesso ao console do script de conteúdo é feito através do console da própria página onde está sendo executado.
Message exchange
Então, precisamos estabelecer dois canais de comunicação: inpage <-> background e popup <-> background. Claro, podemos simplesmente enviar mensagens para a porta e inventar nosso próprio protocolo, mas prefiro a abordagem que vi no projeto de código aberto metamask.
Esta é uma extensão de navegador para trabalhar com a rede Ethereum. Nela, diferentes partes do aplicativo se comunicam via RPC usando a biblioteca dnode. Ela permite organizar a troca de forma rápida e conveniente, se fornecermos um stream nodejs como transporte (referindo-se a um objeto que implementa a mesma interface):
import Dnode from "dnode/browser";
// Neste exemplo, concordamos que o cliente chama funções no servidor remotamente, embora nada nos impeça de tornar isso bidirecional
// Servidor
// API que queremos fornecer
const dnode = Dnode({
hello: (cb) => cb(null, "world")
})
// Transporte sobre o qual o dnode funcionará. Qualquer stream nodejs. No navegador, existe a biblioteca 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)
// Cliente
const dnodeClient = Dnode() // Chamada sem argumento significa que não estamos fornecendo API do outro lado
// Isso irá imprimir no console world
dnodeClient.once('remote', remote => {
remote.hello(((err, value) => console.log(value)))
})Agora vamos criar a classe do aplicativo. Ela criará objetos de API para o popup e a página da web, bem como criará o dnode para eles:
import Dnode from 'dnode/browser';
export class SignerApp {
// Retorna o objeto API para ui
popupApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Retorna o objeto API para a página
pageApi(){
return {
hello: cb => cb(null, 'world')
}
}
// Conecta o popup ui
connectPopup(connectionStream){
const api = this.popupApi();
const dnode = Dnode(api);
connectionStream.pipe(dnode).pipe(connectionStream);
dnode.on('remote', (remote) => {
console.log(remote)
})
}
// Conecta a página
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)
})
}
}Aqui e adiante, em vez do objeto global Chrome, usamos extentionApi, que se conecta ao Chrome no navegador do Google e ao browser em outros. Isso é feito para compatibilidade entre navegadores, mas no âmbito deste artigo poderia ser utilizado apenas 'chrome.runtime.connect'.
Vamos criar uma instância do aplicativo no script de background:
import {extensionApi} from './utils/extensionApi';
import {PortStream} from './utils/PortStream';
import {SignerApp} from './SignerApp';
const app = new SignerApp();
// onConnect é acionado ao conectar 'processos' (contentscript, popup, ou página de extensão)
extensionApi.runtime.onConnect.addListener(connectRemote);
function connectRemote(remotePort) {
const processName = remotePort.name;
const portStream = new PortStream(remotePort);
// Ao estabelecer a conexão, você pode especificar um nome, por esse nome definimos quem se conectou, script de conteúdo ou ui
if (processName === 'contentscript'){
const origin = remotePort.sender.url
app.connectPage(portStream, origin)
}else{
app.connectPopup(portStream)
}
}Como dnode trabalha com streams, e nós recebemos uma porta, é necessária uma classe adaptadora. Ela é feita usando a biblioteca readable-stream, que implementa streams do 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 - desconectado'))
}
cb()
}
}Agora vamos criar a conexão na 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(){
// Assim como no classe do aplicativo, criamos a porta, encapsulamos em stream, fazemos 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)
})
});
// Tornamos o objeto API acessível a partir do console
if (DEV_MODE){
global.background = background;
}
}Em seguida, criamos a 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 {
// injeta o script na página
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('Falha na injeção.', e);
}
}Como precisamos da API não no script de conteúdo, mas diretamente na página, fazemos duas coisas:
- Criamos dois streams. Um — em direção à página, sobre o postMessage. Para isso, usamos este dos criadores do metamask. O segundo stream — para o background sobre a porta recebida de
runtime.connect. Conectamos eles. Agora a página terá uma conexão com o background. - Injetamos o script no DOM. Baixamos o script (o acesso a ele foi permitido no manifesto) e criamos uma tag
scriptcom seu conteúdo dentro:
import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
setupConnection();
injectScript();
function setupConnection(){
// Stream para o backend
const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
const backgroundStream = new PortStream(backgroundPort);
// Stream para a página
const pageStream = new PostMessageStream({
name: 'content',
target: 'page',
});
pageStream.pipe(backgroundStream).pipe(pageStream);
}
function injectScript(){
try {
// injeta o script na página
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('Falha na injeção.', e);
}
}Agora criamos o objeto api no inpage e o tornamos global:
import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';
setupInpageApi().catch(console.error);
async function setupInpageApi() {
// Fluxo para o script de conteúdo
const connectionStream = new PostMessageStream({
name: 'page',
target: 'content',
});
const dnode = Dnode();
connectionStream.pipe(dnode).pipe(connectionStream);
// Obtendo o objeto API
const pageApi = await new Promise(resolve => {
dnode.once('remote', api => {
resolve(api)
})
});
// Acesso através da janela
global.SignerApp = pageApi;
}Estamos prontos . Ao conectar uma nova página ao background, podemos ver isso:

API vazia e origem. Do lado da página, podemos chamar a função hello assim:

Trabalhar com funções de callback no JS moderno é ultrapassado, então vamos escrever um pequeno helper para criar dnode, que permite passar para o objeto API em utils.
Os objetos API agora vão parecer assim:
export class SignerApp {
popupApi() {
return {
hello: async () => "world"
}
}
...
}Recebendo objeto do remote da seguinte forma:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// Utilizando as utilidades para mudar todos os callbacks para promises
resolve(transformMethods(cbToPromise, remoteApi))
})
});E a chamada de funções retorna uma promise:

A versão com funções assíncronas está disponível .
No geral, a abordagem com RPC e streams parece bastante flexível: podemos usar multiplexação de streams e criar várias APIs diferentes para diferentes tarefas. Em princípio, dnode pode ser usado em qualquer lugar, o principal é envolver o transporte na forma de stream do 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 se aplica ao nosso caso.
Estado interno e localStorage
Precisamos armazenar o estado interno do aplicativo — pelo menos, as chaves para assinatura. Podemos facilmente adicionar estado ao aplicativo e métodos para alterá-lo na API do popup:
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)
}
}
...
} No background, vamos envolver tudo em uma função e registrar o objeto do aplicativo na janela, para que possamos interagir com ele a partir do 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)
}
}
}Vamos adicionar algumas chaves via console UI e ver como ficou o estado:

O estado precisa ser persistente para que as chaves não sejam perdidas ao reiniciar.
Vamos armazenar no localStorage, sobrescrevendo a cada alteração. Posteriormente, o acesso a ele também será necessário para o UI, e também queremos nos inscrever nas alterações. Com isso em mente, será conveniente criar um armazenamento observável e nos inscrever nas suas mudanças.
Usaremos a biblioteca mobx (). A escolha caiu sobre ela, pois não tínhamos trabalhado com ela antes e queríamos muito estudá-la.
Vamos adicionar a inicialização do estado inicial e tornar o store observável:
import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
export class SignerApp {
constructor(initState = {}) {
// Externamente, o store continuará sendo o mesmo objeto, mas agora todos os seus campos se tornaram proxies que rastreiam o acesso a eles
this.store = observable.object({
keys: initState.keys || [],
});
}
// Métodos que alteram o observable costumam ser decorados
@action
addKey(key) {
this.store.keys.push(key)
}
@action
removeKey(index) {
this.store.keys.splice(index, 1)
}
...
}"Por trás das câmaras", o mobx substituiu todos os campos do store por proxies e intercepta todos os acessos a eles. Será possível se inscrever nesses acessos.
A partir de agora, estarei usando frequentemente o termo "ao alterar", embora isso não seja completamente correto. O mobx rastreia exatamente o acesso aos campos. Ele usa getters e setters dos objetos proxy que a biblioteca cria.
Os decoradores action têm dois propósitos:
- No modo estrito com a flag enforceActions, o mobx proíbe a alteração do estado diretamente. É considerado uma boa prática trabalhar exatamente no modo estrito.
- Mesmo que a função altere o estado várias vezes - por exemplo, mudamos vários campos em várias linhas de código - os observadores são notificados apenas após a conclusão dela. Isso é especialmente importante para o frontend, onde atualizações desnecessárias de estado levam a renderizações indesejadas de elementos. No nosso caso, nem uma nem outra são particularmente relevantes, mas seguiremos as melhores práticas. Decoradores são comumente aplicados a todas as funções que alteram o estado dos campos observáveis.
No background, adicionaremos a inicialização e a persistência do estado no localStorage:
import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Métodos auxiliares. Gravem/leiam o objeto no/para o localStorage no formato de string JSON pela chave '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;
}
// Configuração de persistência de estado
// O resultado da reação é atribuído a uma variável para que a assinatura possa ser cancelada. Não precisamos disso, mas foi deixado como exemplo
const localStorageReaction = reaction(
() => toJS(app.store), // Função-seletor de dados
saveState // Função que será chamada quando os dados retornados pelo seletor mudarem
);
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 reaction é interessante aqui. Ela tem dois argumentos:
- Um seletor de dados.
- Um manipulador que será chamado com esses dados toda vez que eles mudarem.
Ao contrário do redux, onde obtemos claramente o estado como argumento, o mobx memoriza quais observáveis estamos acessando dentro do seletor e chama o manipulador apenas quando estes mudam.
É importante entender como exatamente o mobx decide a quais observáveis nos inscrevemos. Se no código eu escrevesse o seletor assim() => app.store, a reação nunca seria chamada, pois o próprio armazenamento não é observável; apenas seus campos são.
Se eu escrevesse assim () => app.store.keys, novamente nada aconteceria, pois ao adicionar/remover elementos do array, a referência a ele não mudaria.
Mobx executa a função seletora pela primeira vez e monitora apenas aqueles observáveis aos quais tivemos acesso. Isso é feito por meio de getters proxy. Portanto, aqui foi utilizada a função embutida toJS. Ela retorna um novo objeto no qual todos os proxies são substituídos pelos campos originais. Durante a execução, ela lê todos os campos do objeto – consequentemente, os getters são acionados.
No console, adicionaremos novamente algumas chaves. Dessa vez, elas também foram parar no localStorage:

Após recarregar a página de fundo, as informações permanecem no lugar.
Todo o código do aplicativo até este momento pode ser visualizado .
Armazenamento seguro de chaves privadas
Armazenar chaves privadas em texto claro não é seguro: sempre há a chance de que você seja invadido, que tenham acesso ao seu computador e assim por diante. Portanto, no localStorage, armazenaremos as chaves de forma criptografada com senha.
Para maior segurança, adicionaremos ao aplicativo um estado bloqueado, no qual não haverá acesso às chaves. Vamos automaticamente converter a extensão para o estado bloqueado após um tempo limite.
Mobx permite armazenar apenas o conjunto mínimo de dados, enquanto o restante é calculado automaticamente com base nesse conjunto. Essas são as chamadas propriedades computadas. Elas podem ser comparadas a views em bancos de dados:
import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Utilitários para criptografia segura de strings. Usam crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";
export class SignerApp {
constructor(initState = {}) {
this.store = observable.object({
// Armazenamos a senha e as chaves criptografadas. Se a senha for null - o aplicativo está bloqueado
password: null,
vault: initState.vault,
// Getters para campos computáveis. Pode-se fazer uma analogia com a view em um banco de dados.
get locked(){
return this.password == null
},
get keys(){
return this.locked ?
undefined :
SignerApp._decryptVault(this.vault, this.password)
},
get initialized(){
return this.vault !== undefined
}
})
}
// Inicializa o armazenamento vazio com uma nova senha
@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
)
}
... // código de conexão e api
// privado
_checkPassword(password) {
SignerApp._decryptVault(this.store.vault, password);
}
_checkLocked() {
if (this.store.locked){
throw new Error('O aplicativo está bloqueado')
}
}
// Métodos para criptografar/descriptografar o armazenamento
static _encryptVault(obj, pass){
const jsonString = JSON.stringify(obj)
return encrypt(jsonString, pass)
}
static _decryptVault(str, pass){
if (str === undefined){
throw new Error('Armazenamento não inicializado')
}
try {
const jsonString = decrypt(str, pass)
return JSON.parse(jsonString)
}catch (e) {
throw new Error('Senha incorreta')
}
}
}Agora estamos armazenando apenas chaves criptografadas e a senha. Todo o resto é calculado. A transição para o estado bloqueado é feita removendo a senha do estado. Um método foi adicionado à API pública para inicializar o armazenamento.
Para criptografia, foram escritos :
import CryptoJS from 'crypto-js'
// Usado para dificultar a tentativa de força bruta da senha. Para cada tentativa de senha, o invasor terá que fazer 5000 hashes
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)
}O navegador possui uma API de inatividade, através da qual é possível se inscrever para o evento de alteração de estado. O estado, consequentemente, pode ser ocioso, ativo e bloqueado. Para inatividade, é possível configurar um tempo limite, e bloqueado é definido quando o próprio sistema operacional está bloqueado. Além disso, mudaremos o seletor para salvar no 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;
}
// Agora chamamos explicitamente o campo ao qual será feito o acesso, a reação funcionará corretamente
reaction(
() => ({
vault: app.store.vault
}),
saveState
);
// Tempo limite de inatividade, quando o evento é acionado
extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
// Se o usuário bloqueou a tela ou não atuou durante o intervalo especificado, bloqueamos o aplicativo
extensionApi.idle.onStateChanged.addListener(state => {
if (['locked', 'idle'].indexOf(state) > -1) {
app.lock()
}
});
// Conectar a outros contextos
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)
}
}
}O código até este ponto está .
Transações
Então, chegamos ao mais importante: a criação e assinatura de transações na blockchain. Vamos usar a blockchain WAVES e a biblioteca .
Para começar, adicionaremos ao estado um array de mensagens que precisam ser assinadas, e em seguida — métodos para adicionar uma nova mensagem, confirmar a assinatura e recusar:
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) {
// Para cada mensagem, criamos metadados com id, status, data de criação, etc.
const message = observable.object({
id: uuid(), // Identificador, usando uuid
origin, // Mostraremos a origem na interface posteriormente
data, //
status: 'new', // Os status são: new, signed, rejected e failed
timestamp: Date.now()
});
console.log(`nova mensagem: ${JSON.stringify(message, null, 2)}`);
this.store.messages.push(message);
// Retornamos uma promessa dentro da qual o mobx monitora mudanças na mensagem. Assim que o status mudar, resolveremos isso
return new Promise((resolve, reject) => {
reaction(
() => message.status, // Vamos observar o status da mensagem
(status, reaction) => { // o segundo argumento é uma referência à própria reação, para que possamos destruí-la dentro da chamada
switch (status) {
case 'signed':
resolve(message.data);
break;
case 'rejected':
reject(new Error('Usuário rejeitou a mensagem'));
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(`Nenhuma msg com 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(`Nenhuma msg com id:${id}`);
message.status = 'rejected'
}
...
}Ao receber uma nova mensagem, adicionamos metadados a ela, fazendo observável e adicionamos em store.messages.
Se não fizermos isso observável manualmente, o mobx fará isso ao adicionar ao array de messages. No entanto, ele criará um novo objeto, do qual não teremos uma referência, e precisaremos dela para o próximo passo.
Em seguida, retornamos uma promessa que se resolve quando o status da mensagem mudar. O status é monitorado por uma reação, que se "destruirá" automaticamente ao mudar de status.
O código dos métodos approve e reject é muito simples: apenas mudamos o status da mensagem, previamente assinando-a, se necessário.
Aprovar e rejeitar são disponibilizados na UI da API, newMessage — na página da 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)
}
}
...
}Agora vamos tentar assinar a transação com a extensão:

No geral, tudo está pronto, só falta .
UI
A interface precisa de acesso ao estado da aplicação. No lado da UI, faremos observável o estado e adicionaremos uma função na API que mudará esse estado. Vamos adicionar observável no objeto da API, obtido do 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() {
// Conectando à porta, criando um stream a partir dela
const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
const connectionStream = new PortStream(backgroundPort);
// Criando um observable vazio para o estado do background
let backgroundState = observable.object({});
const api = {
// Damos ao background uma função que atualizará o observable
updateState: async state => {
Object.assign(backgroundState, state)
}
};
// Criando objeto RPC
const dnode = setupDnode(connectionStream, api);
const background = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
resolve(transformMethods(cbToPromise, remoteApi))
})
});
// Adicionamos ao background o observable com o estado
background.state = backgroundState;
if (DEV_MODE) {
global.background = background;
}
// Iniciando a interface
await initApp(background)
}
No final, iniciamos o render da interface da aplicação. Este é um aplicativo React. O objeto do background é simplesmente passado através de props. Claro que seria melhor criar um serviço separado para os métodos e um store para o estado, mas dentro do escopo deste artigo, isso é suficiente:
import {render} from 'react-dom'
import App from './App'
import React from "react";
// Inicializamos o aplicativo com o objeto background como props
export async function initApp(background){
render(
,
document.getElementById('app-content')
);
}
Com o mobx, é muito fácil iniciar o render quando os dados mudam. Nós simplesmente usamos o decorador observer do pacote O componente será chamado automaticamente ao alterar qualquer observable ao qual o componente se refere. Não são necessários mapStateToProps ou connect, como no Redux. Tudo funciona imediatamente "fora da caixa":
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 // O componente com este decorator chamará automaticamente o método render se houver alterações nos observáveis aos quais ele se refere
export default class App extends Component {
// É correto, claro, extrair a lógica de renderização das páginas na roteirização e não usar operadores ternários aninhados,
// e vincular observáveis e métodos de fundo diretamente aos componentes que os utilizam
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()}>Bloquear App</button>}
{initialized && <button onclick="{()" > deleteVault()}>Excluir todas as chaves e inicializar</button>}
</div>
</Fragment>
}
}Os outros componentes podem ser visualizados no código .
Agora, na classe do aplicativo, é necessário criar um seletor de estado para a UI e notificar a UI quando houver uma alteração. Para isso, adicionaremos o método getState e reaction, chamando 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 {
...
// público
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) => {
// Criamos uma reação nas mudanças de estado, que chamará a função remota e atualizará o estado no processo da UI
const updateStateReaction = reaction(
() => this.getState(),
(state) => remote.updateState(state),
// O terceiro argumento pode receber parâmetros. fireImmediately significa que a reação será executada imediatamente da primeira vez.
// Isso é necessário para obter o estado inicial. Delay permite configurar o debounce
{fireImmediately: true, delay: 500}
);
// Removeremos a assinatura ao desconectar o cliente
dnode.once('end', () => updateStateReaction.dispose())
})
}
...
}Ao receber o objeto remoto uma alteração é criada reaction no estado, que chama a função do lado da UI.
O último toque — vamos adicionar a exibição de novas mensagens no ícone da extensão:
function setupApp() {
...
// Reação para definir o texto da badge.
reaction(
() => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
text => extensionApi.browserAction.setBadgeText({text}),
{fireImmediately: true}
);
...
}Então, o aplicativo está pronto. As páginas web podem solicitar a assinatura de transações:


O código está disponível neste .
Conclusão
Se você leu o artigo até o final, mas ainda tem perguntas, pode fazê-las no . Lá você encontrará commits para cada passo indicado.
E se você tiver interesse em ver o código de uma extensão real, você poderá encontrar isso .
Código, repositório e descrição do funcionamento de
Fonte: habr.com
