Escribir una extensión de navegador segura


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.


  1. fondo : un objeto que incluye los siguientes campos:
    1. scripts : una serie de scripts que se ejecutarán en el contexto de fondo (hablaremos de esto un poco más adelante);
    2. 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;
    3. 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.
  2. content_scripts : una matriz de objetos que le permite cargar diferentes scripts en diferentes páginas web. Cada objeto contiene los siguientes campos importantes:
    1. coincidencias : patrón de URL mediante el cual se determina si se incluirá o no un script de contenido específico.
    2. js : una lista de scripts que se cargarán en esta coincidencia;
    3. exclude_matches : excluye las URL de match campo de coincidencia que coincide con este campo.
  3. 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.
    1. default_popup : la ruta al archivo HTML con una interfaz emergente puede contener CSS y JS.
  4. permisos : una matriz para administrar los derechos de extensión. Hay 3 tipos de derechos que se describen en detalle aquí.
  5. web_accessible_resources : recursos de extensión que una página web puede solicitar, por ejemplo, imágenes, JS, CSS, archivos HTML.
  6. 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":


  1. Página de fondo : parte “backend” de la extensión. El archivo se indica en el manifiesto mediante la tecla "fondo".
  2. Página emergente: página emergente que aparece cuando hace clic en el icono de extensión. En el manifiesto, browser_action -> default_popup .
  3. 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.


 //     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 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í:


 //   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 o fondo:


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

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:


 { //   , .        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"] } 

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

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

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

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

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 { // 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 no necesitamos la API en el script de contenido, sino directamente en la página, hacemos dos cosas:


  1. 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.
  2. 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(){ //    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); } } 

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() { //    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 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 => { //      callback  promise resolve(transformMethods(cbToPromise, 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 = {}) { //  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) } ... } 

"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:


  1. En modo estricto con la bandera enforceActions mobx prohíbe cambiar el estado directamente. Se considera una buena práctica trabajar en modo estricto.
  2. 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"; //  . /  / 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) } } } 

La función de reacción es interesante aquí. Ella tiene dos argumentos:


  1. Selector de datos.
  2. 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"; //     .  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) } } } 

.


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

, . - :




.


Conclusión


, , . .


, .


, siemarell

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


All Articles