
A diferencia de la arquitectura común de "cliente-servidor", las aplicaciones descentralizadas se caracterizan por:
- No es necesario almacenar una base de datos con nombres de usuario y contraseñas. La información de acceso es almacenada exclusivamente por los propios usuarios, y la confirmación de su autenticidad ocurre a nivel de protocolo.
- No es necesario usar un servidor. La lógica de la aplicación se puede ejecutar en una red blockchain, donde es posible almacenar la cantidad requerida de datos.
Hay 2 repositorios relativamente seguros para las claves de usuario: billeteras de hardware y extensiones de navegador. La mayoría de las billeteras de hardware son lo más seguras posible, pero son difíciles de usar y están lejos de ser gratuitas, pero las extensiones del navegador son la combinación perfecta de seguridad y facilidad de uso, y también pueden ser completamente gratuitas para los usuarios finales.
Dado todo esto, queríamos hacer la extensión más segura, lo que simplifica el desarrollo de aplicaciones descentralizadas, proporcionando una API simple para trabajar con transacciones y firmas.
Le contaremos sobre esta experiencia a continuación.
El artículo proporcionará instrucciones paso a paso sobre cómo escribir una extensión del navegador, con ejemplos de código y capturas de pantalla. Puede encontrar todo el código en el repositorio . Cada confirmación corresponde lógicamente a una sección de este artículo.
Una breve historia de las extensiones del navegador
Las extensiones del navegador han existido durante bastante tiempo. En Internet Explorer, aparecieron en 1999, en Firefox, en 2004. Sin embargo, durante mucho tiempo no hubo un estándar único para las extensiones.
Podemos decir que apareció junto con extensiones en la cuarta versión de Google Chrome. Por supuesto, no había una especificación entonces, pero fue la API de Chrome la que se convirtió en su base: habiendo conquistado una gran parte del mercado de navegadores y teniendo una tienda de aplicaciones incorporada, Chrome en realidad estableció el estándar para las extensiones del navegador.
Mozilla tenía su propio estándar, pero, al ver la popularidad de las extensiones para Chrome, la compañía decidió hacer una API compatible. En 2015, por iniciativa de Mozilla, se creó un grupo especial dentro del Consorcio World Wide Web (W3C) para trabajar en las especificaciones de las extensiones entre navegadores.
Basado en las extensiones API ya existentes para Chrome. El trabajo fue apoyado por Microsoft (Google se negó a participar en el desarrollo del estándar) y, como resultado, apareció un borrador de especificación .
Formalmente, la especificación es compatible con Edge, Firefox y Opera (tenga en cuenta que Chrome no está en esta lista). Pero, de hecho, el estándar es en gran medida compatible con Chrome, ya que en realidad está escrito en función de sus extensiones. Lea más sobre la API de WebExtensions aquí .
Estructura de extensión
El único archivo que se requiere para la extensión es el manifiesto (manifest.json). Él es el "punto de entrada" a la extensión.
Manifiesto
Por especificación, el archivo de manifiesto es un archivo JSON válido. Una descripción completa de las claves de manifiesto con información sobre qué claves son compatibles en qué navegador se puede encontrar aquí .
Las claves que no están en la especificación "pueden" ignorarse (tanto Chrome como Firefox informan errores, pero las extensiones continúan funcionando).
Y me gustaría llamar la atención sobre algunos puntos.
- fondo : un objeto que incluye los siguientes campos:
- scripts : una serie de scripts que se ejecutarán en el contexto de fondo (hablaremos de esto un poco más adelante);
- página : en lugar de los scripts que se ejecutarán en una página en blanco, puede especificar html con contenido. En este caso, el campo de secuencia de comandos se ignorará y las secuencias de comandos deberán insertarse en la página con el contenido;
- persistente : un indicador binario, si no se especifica, el navegador "matará" el proceso en segundo plano cuando considere que no está haciendo nada, y se reiniciará si es necesario. De lo contrario, la página se descargará solo cuando se cierre el navegador. No es compatible con Firefox.
- content_scripts : una matriz de objetos que le permite cargar diferentes scripts en diferentes páginas web. Cada objeto contiene los siguientes campos importantes:
- coincidencias : patrón de URL mediante el cual se determina si se incluirá o no un script de contenido específico.
- js : una lista de scripts que se cargarán en esta coincidencia;
- exclude_matches : excluye las URL de
match
campo de coincidencia que coincide con este campo.
- page_action : de hecho, es el objeto el responsable del icono que aparece junto a la barra de direcciones en el navegador y la interacción con él. También le permite mostrar una ventana emergente, que se configura con HTML, CSS y JS.
- default_popup : la ruta al archivo HTML con una interfaz emergente puede contener CSS y JS.
- permisos : una matriz para administrar los derechos de extensión. Hay 3 tipos de derechos que se describen en detalle aquí.
- web_accessible_resources : recursos de extensión que una página web puede solicitar, por ejemplo, imágenes, JS, CSS, archivos HTML.
- externally_connectable : aquí puede especificar explícitamente los ID de otras extensiones y los dominios de las páginas web desde las que puede conectarse. Un dominio puede ser de segundo nivel o superior. No funciona en Firefox.
Contexto de ejecución
La extensión tiene tres contextos de ejecución de código, es decir, la aplicación consta de tres partes con diferentes niveles de acceso a la API del navegador.
Contexto de extensión
La mayoría de las API están disponibles aquí. En este contexto, "vivir":
- Página de fondo : parte “backend” de la extensión. El archivo se indica en el manifiesto mediante la tecla "fondo".
- Página emergente: página emergente que aparece cuando hace clic en el icono de extensión. En el manifiesto,
browser_action
-> default_popup
. - Página personalizada: página de extensión, "viva" en una pestaña separada del formulario
chrome-extension://<id_>/customPage.html
.
Este contexto existe independientemente de las ventanas y pestañas del navegador. La página de fondo existe en una sola copia y siempre funciona (la excepción es la página del evento, cuando el script de fondo se inicia en un evento y muere después de ejecutarse). La página emergente existe cuando la ventana emergente está abierta, y la página personalizada , mientras la pestaña con ella está abierta. No hay acceso a otras pestañas y sus contenidos desde este contexto.
Contexto del guión de contenido
El archivo de secuencia de comandos de contenido se inicia junto con cada pestaña del navegador. Tiene acceso a parte de la API de extensión y al árbol DOM de la página web. Los guiones de contenido son responsables de interactuar con la página. Las extensiones que manipulan el árbol DOM lo hacen en secuencias de comandos de contenido, por ejemplo, bloqueadores de anuncios o traductores. Además, el script de contenido puede comunicarse con la página a través de postMessage
estándar.
Contexto de la página web
Esta es en realidad la página web en sí. No tiene nada que ver con la extensión y no tiene acceso allí, a menos que el dominio de esta página no se especifique explícitamente en el manifiesto (más sobre esto a continuación).
Mensajería
Las diferentes partes de la aplicación deben intercambiar mensajes entre sí. Para hacer esto, hay una API runtime.sendMessage
para enviar un mensaje de background
y tabs.sendMessage
para enviar un mensaje a una página (secuencia de comandos de contenido, ventana emergente o página web si hay una runtime.sendMessage
externally_connectable
). El siguiente es un ejemplo al acceder a la API de Chrome.
Para una comunicación completa, puede crear conexiones a través de runtime.connect
. En respuesta, obtenemos runtime.Port
, en el que, mientras está abierto, puede enviar cualquier cantidad de mensajes. En el lado del cliente, por ejemplo, contentscript
, se ve así:
Servidor o fondo:
También hay un evento onDisconnect
y un método de disconnect
.
Esquema de la aplicación
Hagamos una extensión de navegador que almacene claves privadas, proporcione acceso a información pública (la dirección, la clave pública se comunica con la página y permite que aplicaciones de terceros soliciten una firma de transacción.
Desarrollo de aplicaciones
Nuestra aplicación debe interactuar con el usuario y proporcionar una página API para los métodos de llamada (por ejemplo, para firmar transacciones). No funcionará solo con el contentscript
, ya que solo tiene acceso al DOM, pero no a la página JS. No podemos conectarnos a través de runtime.connect
, porque la API es necesaria en todos los dominios y solo se pueden especificar algunos específicos en el manifiesto. Como resultado, el esquema se verá así:

Habrá otro script: inpage
, que inyectaremos en la página. Se ejecutará en su contexto y proporcionará una API para trabajar con la extensión.
Inicio
Todo el código de extensión del navegador está disponible en GitHub . En el proceso de descripción, habrá enlaces a confirmaciones.
Comencemos con el manifiesto:
{
Cree background.js, popup.js, inpage.js y contentscript.js vacíos. Agregue popup.html, y nuestra aplicación ya se puede descargar en Google Chrome y asegúrese de que funcione.
Para verificar esto, puede tomar el código desde aquí . Además de lo que hicimos, el enlace está configurado para construir el proyecto usando webpack. Para agregar una aplicación al navegador, en chrome: // extensiones debe seleccionar cargar desempaquetado y la carpeta con la extensión correspondiente, en nuestro caso, dist.

Ahora nuestra extensión está instalada y funcionando. Puede ejecutar herramientas de desarrollador para diferentes contextos de la siguiente manera:
ventana emergente ->

El acceso a la consola del script de contenido se realiza a través de la consola de la página en la que se inicia. 
Mensajería
Entonces, necesitamos establecer dos canales de comunicación: inpage <-> background y popup <-> background. Puede, por supuesto, simplemente enviar mensajes al puerto e inventar su protocolo, pero prefiero el enfoque que vi en el proyecto de metamask de código abierto.
Esta es una extensión del navegador para trabajar con la red Ethereum. En él, diferentes partes de la aplicación se comunican a través de RPC utilizando la biblioteca dnode. Le permite organizar un intercambio de manera rápida y conveniente si proporciona un flujo de nodejs como transporte (es decir, un objeto que implementa la misma interfaz):
import Dnode from "dnode/browser";
Ahora crearemos una clase de aplicación. Creará objetos API para ventanas emergentes y páginas web, y también creará dnode para ellos:
import Dnode from 'dnode/browser'; export class SignerApp {
De aquí en adelante, en lugar del objeto global de Chrome, usamos extensiónApi, que se refiere a Chrome en el navegador de Google y al navegador en otros. Esto se hace para la compatibilidad entre navegadores, pero simplemente chrome.runtime.connect podría usarse en el marco de este artículo.
Cree la instancia de la aplicación en el script de fondo:
import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {SignerApp} from "./SignerApp"; const app = new SignerApp();
Dado que dnode funciona con transmisiones y obtenemos el puerto, se necesita una clase de adaptador. Se realiza utilizando la biblioteca de flujo legible, que implementa flujos de nodejs en el 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() } }
Ahora cree una conexión en la interfaz de usuario:
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(){
Luego creamos una conexión en el script de contenido:
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 {
Como no necesitamos la API en el script de contenido, sino directamente en la página, hacemos dos cosas:
- Creamos dos corrientes. Uno es hacia la página, encima de postMessage. Para esto usamos este paquete de los creadores de metamask. La segunda secuencia es en segundo plano en la parte superior del puerto recibido de
runtime.connect
. Pip ellos. Ahora la página tendrá una secuencia en segundo plano. - Inyecte el script en el DOM. Bombeamos el script (se permitió el acceso al mismo en el manifiesto) y creamos una etiqueta de
script
con su contenido dentro:
import PostMessageStream from 'post-message-stream'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; setupConnection(); injectScript(); function setupConnection(){
Ahora cree un objeto api en inpage y comience globalmente:
import PostMessageStream from 'post-message-stream'; import Dnode from 'dnode/browser'; setupInpageApi().catch(console.error); async function setupInpageApi() {
Estamos listos para la llamada a procedimiento remoto (RPC) con una API separada para la página y la interfaz de usuario . Al conectar una nueva página al fondo, podemos ver esto:

API vacía y origen. En el lado de la página, podemos llamar a la función hello de esta manera:

Trabajar con funciones de devolución de llamada en JS moderno es una mala idea, por lo tanto, escribiremos un pequeño ayudante para crear un nodo que le permita pasar API a utilidades en un objeto.
Los objetos API ahora se verán así:
export class SignerApp { popupApi() { return { hello: async () => "world" } } ... }
Obtener un objeto de forma remota de la siguiente manera:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode"; const pageApi = await new Promise(resolve => { dnode.once('remote', remoteApi => {
Una llamada a función devuelve una promesa:

Una versión con funciones asincrónicas está disponible aquí .
En general, el enfoque con RPC y transmisiones parece bastante flexible: podemos usar la multiplexación de vapor y crear varias API diferentes para diferentes tareas. En principio, dnode se puede usar en cualquier lugar, lo principal es envolver el transporte en forma de una secuencia de nodejs.
Una alternativa es el formato JSON, que implementa el protocolo JSON RPC 2. Sin embargo, funciona con transportes específicos (TCP y HTTP (S)), que no es aplicable en nuestro caso.
Almacenamiento interno estatal y local
Tendremos que almacenar el estado interno de la aplicación, al menos, claves para firmar. Podemos agregar fácilmente el estado a la aplicación y los métodos para cambiarlo en la API emergente:
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) } } ... }
En segundo plano, envolveremos todo en una función y escribiremos el objeto de la aplicación en la ventana para que pueda trabajar con él desde la consola:
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) } } }
Agregue algunas claves de la consola de la interfaz de usuario y vea qué sucedió con el estado:

El estado debe ser persistente para que cuando reinicie las claves no se pierdan.
Lo almacenaremos en localStorage, sobrescribiendo con cada cambio. Posteriormente, el acceso a él también será necesario para la interfaz de usuario, y también quiero suscribirme a los cambios. En base a esto, será conveniente hacer un almacenamiento observable y suscribirse a sus cambios.
Utilizaremos la biblioteca mobx ( https://github.com/mobxjs/mobx ). La elección recayó en ella, ya que no tenía que trabajar con ella, pero realmente quería estudiarla.
Agregue la inicialización del estado inicial y haga que la tienda sea observable:
import {observable, action} from 'mobx'; import {setupDnode} from "./utils/setupDnode"; export class SignerApp { constructor(initState = {}) {
"Under the hood" mobx reemplazó todos los campos de la tienda con proxy e intercepta todas las llamadas a ellos. Puede suscribirse a estas apelaciones.
Además, a menudo utilizaré el término "al cambiar", aunque esto no es del todo correcto. Mobx rastrea el acceso a los campos. Se utilizan los captadores y establecedores de objetos proxy que crea la biblioteca.
Los decoradores de acción tienen dos propósitos:
- En modo estricto con la bandera enforceActions mobx prohíbe cambiar el estado directamente. Se considera una buena práctica trabajar en modo estricto.
- Incluso si la función cambia el estado varias veces, por ejemplo, cambiamos varios campos a varias líneas de código, los observadores reciben una notificación solo cuando está completa. Esto es especialmente importante para el frontend, donde las actualizaciones de estado innecesarias conducen a la representación innecesaria de elementos. En nuestro caso, ni el primero ni el segundo son particularmente relevantes, sin embargo, seguiremos las mejores prácticas. Los decoradores decidieron suspender todas las funciones que cambian el estado de los campos observados.
En segundo plano, agregue la inicialización y guarde el estado en localStorage:
import {reaction, toJS} from 'mobx'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {SignerApp} from "./SignerApp";
La función de reacción es interesante aquí. Ella tiene dos argumentos:
- Selector de datos.
- Un controlador que se llamará con estos datos cada vez que cambie.
A diferencia de redux, donde obtenemos explícitamente el estado como argumento, mobx recuerda a qué observable nos referimos dentro del selector, y solo al cambiarlos llama al controlador.
Es importante comprender exactamente cómo mobx decide a qué observable nos estamos suscribiendo. Si escribí el selector en el código como este () => app.store
, entonces la reacción nunca se llamará, ya que el repositorio en sí no es observable, solo sus campos son tales.
Si escribiera así () => app.store.keys
, entonces no volvería a pasar nada, ya que al agregar / eliminar elementos de la matriz, el enlace no cambiará.
Por primera vez, Mobx realiza la función de un selector y monitorea solo aquellos observables a los que tenemos acceso. Esto se hace a través de getters proxy. toJS
. , . – , .
popup . localStorage:

background- .
.
: , , . localStorage .
locked, . locked .
Mobx , . — computed properties. view :
import {observable, action} from 'mobx'; import {setupDnode} from "./utils/setupDnode";
. . locked . API .
rypto-js :
import CryptoJS from 'crypto-js'
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; }
.
Transacciones
, : . 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) {
, 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() {
. 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 { ...
remote
reaction
, UI.
— :
function setupApp() { ...
, . - :


.
Conclusión
, , . .
, .
, siemarell