Écrire une extension de navigateur sécurisée


Contrairement à l'architecture "client-serveur" commune, les applications décentralisées se caractérisent par:


  • Pas besoin de stocker une base de données avec les identifiants et les mots de passe des utilisateurs. Les informations d'accès sont stockées exclusivement par les utilisateurs eux-mêmes et la confirmation de leur authenticité se produit au niveau du protocole.
  • Pas besoin d'utiliser un serveur. La logique d'application peut être exécutée sur un réseau blockchain, où il est possible de stocker la quantité de données requise.

Il existe 2 référentiels relativement sécurisés pour les clés utilisateur - les portefeuilles matériels et les extensions de navigateur. La plupart des portefeuilles matériels sont aussi sécurisés que possible, mais ils sont difficiles à utiliser et loin d'être gratuits, mais les extensions de navigateur sont la combinaison parfaite de sécurité et de facilité d'utilisation, et elles peuvent également être entièrement gratuites pour les utilisateurs finaux.


Compte tenu de tout cela, nous voulions faire l'extension la plus sécurisée, ce qui simplifie le développement d'applications décentralisées, en fournissant une API simple pour travailler avec les transactions et les signatures.
Nous vous parlerons de cette expérience ci-dessous.


L'article fournira des instructions étape par étape sur la façon d'écrire une extension de navigateur, avec des exemples de code et des captures d'écran. Vous pouvez trouver tout le code dans le référentiel . Chaque commit correspond logiquement à une section de cet article.


Un bref historique des extensions de navigateur


Les extensions de navigateur existent depuis un certain temps. Dans Internet Explorer, ils sont apparus en 1999, dans Firefox - en 2004. Cependant, pendant très longtemps, il n'y avait pas de norme unique pour les extensions.


Nous pouvons dire qu'il est apparu avec des extensions dans la quatrième version de Google Chrome. Bien sûr, il n'y avait pas de spécification à l'époque, mais c'est l'API Chrome qui est devenue sa base: ayant conquis une grande partie du marché des navigateurs et disposant d'un magasin d'applications intégré, Chrome a en fait établi la norme pour les extensions de navigateur.


Mozilla avait son propre standard, mais, vu la popularité des extensions pour Chrome, la société a décidé de créer une API compatible. En 2015, à l'initiative de Mozilla, un groupe spécial a été créé au sein du World Wide Web Consortium (W3C) pour travailler sur les spécifications des extensions multi-navigateurs.


Basé sur les extensions API déjà existantes pour Chrome. Le travail a été soutenu par Microsoft (Google a refusé de participer à l'élaboration de la norme), et en conséquence, un projet de spécification est apparu.


Formellement, la spécification est prise en charge par Edge, Firefox et Opera (notez que Chrome ne figure pas dans cette liste). Mais en fait, la norme est largement compatible avec Chrome, car elle est en fait écrite en fonction de ses extensions. En savoir plus sur l'API WebExtensions ici .


Structure d'extension


Le seul fichier requis pour l'extension est le manifeste (manifest.json). Il est le "point d'entrée" de l'extension.


Manifeste


Par spécification, le fichier manifeste est un fichier JSON valide. Une description complète des clés du manifeste avec des informations sur les clés prises en charge dans quel navigateur peut être trouvée ici .


Les clés qui ne figurent pas dans la spécification peuvent être «ignorées» (Chrome et Firefox signalent des erreurs, mais les extensions continuent de fonctionner).


Et je voudrais attirer l'attention sur certains points.


  1. background - un objet qui comprend les champs suivants:
    1. scripts - un tableau de scripts qui seront exécutés en arrière-plan (nous en parlerons un peu plus tard);
    2. page - au lieu de scripts qui seront exécutés sur une page vierge, vous pouvez spécifier du HTML avec du contenu. Dans ce cas, le champ de script sera ignoré et les scripts devront être insérés dans la page avec le contenu;
    3. persistante - un indicateur binaire, s'il n'est pas spécifié, le navigateur «tuera» le processus d'arrière-plan lorsqu'il considère qu'il ne fait rien et redémarrera si nécessaire. Sinon, la page ne sera déchargée que lorsque le navigateur sera fermé. Non pris en charge dans Firefox.
  2. content_scripts - un tableau d'objets qui vous permet de charger différents scripts sur différentes pages Web. Chaque objet contient les champs importants suivants:
    1. matches - modèle d'URL par lequel il est déterminé si un script de contenu spécifique sera inclus ou non.
    2. js - une liste de scripts qui seront chargés dans cette correspondance;
    3. exclude_matches - exclut les URL de match champ de correspondance qui correspondent à ce champ.
  3. page_action - en fait, c'est l'objet qui est responsable de l'icône qui apparaît à côté de la barre d'adresse dans le navigateur et de l'interaction avec elle. Il vous permet également d'afficher une fenêtre contextuelle, qui est définie à l'aide de son HTML, CSS et JS.
    1. default_popup - chemin vers le fichier HTML avec une interface popup, peut contenir CSS et JS.
  4. autorisations - un tableau pour gérer les droits d'extension. Il existe 3 types de droits qui sont décrits en détail ici.
  5. web_accessible_resources - ressources d'extension qu'une page Web peut demander, par exemple, des images, JS, CSS, fichiers HTML.
  6. externally_connectable - ici, vous pouvez spécifier explicitement les ID des autres extensions et les domaines des pages Web à partir desquelles vous pouvez vous connecter. Un domaine peut être d'un deuxième niveau ou supérieur. Ne fonctionne pas dans Firefox.

Contexte d'exécution


L'extension a trois contextes d'exécution de code, c'est-à-dire que l'application se compose de trois parties avec différents niveaux d'accès à l'API du navigateur.


Contexte d'extension


La plupart des API sont disponibles ici. Dans ce contexte, "live":


  1. Page de fond - partie «backend» de l'extension. Le fichier est indiqué dans le manifeste par la touche «background».
  2. Page contextuelle - page contextuelle qui apparaît lorsque vous cliquez sur l'icône d'extension. Dans le manifeste, browser_action -> default_popup .
  3. Page personnalisée - page d' extension, "vivant" dans un onglet séparé du formulaire chrome-extension://<id_>/customPage.html .

Ce contexte existe indépendamment des fenêtres et des onglets du navigateur. La page d' arrière-plan existe en une seule copie et fonctionne toujours (l'exception est la page d'événement, lorsque le script d'arrière-plan est lancé sur un événement et meurt après son exécution). La page contextuelle existe lorsque la fenêtre contextuelle est ouverte, et la page personnalisée - tandis que l'onglet avec elle est ouverte. Il n'y a pas accès aux autres onglets et à leur contenu à partir de ce contexte.


Contexte du script de contenu


Le fichier de script de contenu est lancé avec chaque onglet de navigateur. Il a accès à une partie de l'API d'extension et à l'arborescence DOM de la page Web. Les scripts de contenu sont responsables de l'interaction avec la page. Les extensions qui manipulent l'arborescence DOM le font dans les scripts de contenu - par exemple, les bloqueurs de publicités ou les traducteurs. De plus, le script de contenu peut communiquer avec la page via postMessage standard.


Contexte de la page Web


Il s'agit en fait de la page Web elle-même. Cela n'a rien à voir avec l'extension et n'y a pas accès, sauf si le domaine de cette page n'est pas explicitement spécifié dans le manifeste (plus d'informations à ce sujet ci-dessous).


Messagerie


Différentes parties de l'application doivent échanger des messages entre elles. Pour ce faire, il existe une API runtime.sendMessage pour envoyer un message en background - background et tabs.sendMessage pour envoyer un message à une page (script de contenu, popup ou page Web si externally_connectable présent). Voici un exemple lors de l'accès à l'API 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)) } ) 

Pour une communication complète, vous pouvez créer des connexions via runtime.connect . En réponse, nous obtenons runtime.Port , dans lequel, pendant qu'il est ouvert, vous pouvez envoyer n'importe quel nombre de messages. Côté client, par exemple contentscript , cela ressemble à ceci:


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

Serveur ou arrière-plan:


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

Il existe également un événement onDisconnect et une méthode de disconnect .


Aperçu de l'application


Faisons une extension de navigateur qui stocke les clés privées, donne accès aux informations publiques (l'adresse, la clé publique communique avec la page et permet aux applications tierces de demander une signature de transaction.


Développement d'applications


Notre application doit à la fois interagir avec l'utilisateur et fournir une page API pour appeler des méthodes (par exemple, pour signer des transactions). Il ne fonctionnera pas uniquement avec contentscript , car il n'a accès qu'au DOM, mais pas à la page JS. Nous ne pouvons pas nous connecter via runtime.connect , car l'API est nécessaire sur tous les domaines et seuls certains spécifiques peuvent être spécifiés dans le manifeste. En conséquence, le schéma ressemblera à ceci:



Il y aura un autre script - inpage , que nous injecterons dans la page. Il s'exécutera dans son contexte et fournira une API pour travailler avec l'extension.


Commencer


Tout le code d'extension du navigateur est disponible sur GitHub . Dans le processus de description, il y aura des liens vers les validations.


Commençons par le manifeste:


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

Créez background.js, popup.js, inpage.js et contentscript.js vides. Ajoutez popup.html - et notre application peut déjà être téléchargée dans Google Chrome et assurez-vous qu'elle fonctionne.


Pour vérifier cela, vous pouvez prendre le code d'ici . En plus de ce que nous avons fait, le lien est configuré pour créer le projet à l'aide de webpack. Pour ajouter une application au navigateur, dans les extensions chrome: // vous devez sélectionner load unpacked et le dossier avec l'extension correspondante - dans notre cas, dist.



Maintenant, notre extension est installée et fonctionne. Vous pouvez exécuter des outils de développement pour différents contextes comme suit:


popup ->



L'accès à la console du script de contenu s'effectue via la console de la page elle-même sur laquelle il est lancé.


Messagerie


Nous devons donc établir deux canaux de communication: inpage <-> fond et popup <-> fond. Vous pouvez, bien sûr, simplement envoyer des messages au port et inventer votre protocole, mais je préfère l'approche que j'ai espionnée sur le projet de métamask open source.


Il s'agit d'une extension de navigateur pour travailler avec le réseau Ethereum. Dans ce document, différentes parties de l'application communiquent via RPC en utilisant la bibliothèque dnode. Il vous permet d'organiser rapidement et facilement un échange si vous fournissez le flux nodejs en tant que transport (c'est-à-dire un objet qui implémente la même interface):


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

Nous allons maintenant créer une classe d'application. Il créera des objets API pour les fenêtres contextuelles et les pages Web, et créera également des nœuds pour eux:


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

Ci-après, au lieu de l'objet Chrome global, nous utilisons extentionApi, qui fait référence à Chrome dans le navigateur de Google et au navigateur dans d'autres. Cela est fait pour la compatibilité entre les navigateurs, mais simplement chrome.runtime.connect peut être utilisé dans le cadre de cet article.


Créez l'instance d'application dans le script d'arrière-plan:


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

Étant donné que dnode fonctionne avec les flux et que nous obtenons le port, une classe d'adaptateur est nécessaire. Il est fait en utilisant la bibliothèque de flux lisible, qui implémente les flux nodejs dans le navigateur:


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

Créez maintenant une connexion dans l'interface utilisateur:


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

Ensuite, nous créons une connexion dans le script de contenu:


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

Comme nous n'avons pas besoin de l'API dans le script de contenu, mais directement sur la page, nous faisons deux choses:


  1. Nous créons deux flux. L'un est vers la page, en haut de postMessage. Pour cela, nous utilisons ce package des créateurs de métamask. Le deuxième flux est à l'arrière-plan au-dessus du port reçu de runtime.connect . Pipez-les. Maintenant, la page aura un flux à l'arrière-plan.
  2. Injectez le script dans le DOM. Nous pompons le script (son accès était autorisé dans le manifeste) et créons une balise de script avec son contenu à l'intérieur:

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

Maintenant, créez un objet api dans inpage et démarrez-le global:


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

Nous sommes prêts pour l' appel de procédure à distance (RPC) avec une API distincte pour la page et l'interface utilisateur . Lorsque vous connectez une nouvelle page à l'arrière-plan, nous pouvons voir ceci:



API et origine vides. Côté page, nous pouvons appeler la fonction hello comme ceci:



Travailler avec des fonctions de rappel dans JS moderne est une mauvaise idée, nous allons donc écrire un petit assistant pour créer un dnode qui nous permet de passer des API aux utils dans un objet.


Les objets API ressembleront maintenant à ceci:


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

Obtention d'un objet à distance comme suit:


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

Un appel de fonction renvoie une promesse:



Une version avec des fonctions asynchrones est disponible ici .


En général, l'approche avec RPC et les flux semble assez flexible: nous pouvons utiliser le multiplexage à vapeur et créer plusieurs API différentes pour différentes tâches. En principe, dnode peut être utilisé n'importe où, l'essentiel est d'envelopper le transport sous la forme d'un flux nodejs.


Une alternative est le format JSON, qui implémente le protocole JSON RPC 2. Cependant, il fonctionne avec des transports spécifiques (TCP et HTTP (S)), ce qui n'est pas applicable dans notre cas.


État interne et stockage local


Nous devrons stocker l'état interne de l'application - au moins, les clés de signature. Nous pouvons facilement ajouter l'état à l'application et les méthodes pour le changer dans l'API 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) } } ... } 

En arrière-plan, nous allons tout envelopper dans une fonction et écrire l'objet d'application dans la fenêtre afin que vous puissiez l'utiliser avec la 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) } } } 

Ajoutez quelques clés à partir de la console d'interface utilisateur et voyez ce qui s'est passé avec l'état:



L'état doit être persistant pour que lorsque vous redémarrez les clés ne soient pas perdues.


Nous le stockerons dans localStorage, en le remplaçant à chaque changement. Par la suite, l'accès à celui-ci sera également nécessaire pour l'interface utilisateur, et je souhaite également m'abonner aux modifications. Sur cette base, il sera commode de faire un stockage observable et de souscrire à ses modifications.


Nous utiliserons la bibliothèque mobx ( https://github.com/mobxjs/mobx ). Le choix s'est porté sur elle, car je n'avais pas à travailler avec elle, mais je voulais vraiment l'étudier.


Ajoutez l'initialisation de l'état initial et rendez le magasin 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 a remplacé tous les champs du magasin par un proxy et intercepte tous les appels vers eux. Vous pouvez vous abonner à ces appels.


De plus, j'utiliserai souvent le terme «lors d'un changement», bien que ce ne soit pas tout à fait exact. Mobx suit l'accès aux champs. Les getters et setters des objets proxy créés par la bibliothèque sont utilisés.


Les décorateurs d'action ont deux objectifs:


  1. En mode strict avec l'indicateur enforceActions mobx interdit de changer directement l'état. Il est considéré comme une bonne pratique de travailler en mode strict.
  2. Même si la fonction change l'état plusieurs fois - par exemple, nous changeons plusieurs champs en plusieurs lignes de code - les observateurs ne sont informés que lorsqu'elle est terminée. Ceci est particulièrement important pour le frontend, où des mises à jour d'état inutiles conduisent à un rendu inutile des éléments. Dans notre cas, ni le premier ni le second ne sont particulièrement pertinents, mais nous suivrons les meilleures pratiques. Les décorateurs ont décidé de s'accrocher à toutes les fonctions qui modifient l'état des champs observés.

En arrière-plan, ajoutez l'initialisation et enregistrez l'état dans 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 fonction de réaction est intéressante ici. Elle a deux arguments:


  1. Sélecteur de données.
  2. Un gestionnaire qui sera appelé avec ces données chaque fois qu'elles changent.

Contrairement à redux, où nous obtenons explicitement l'état en tant qu'argument, mobx se souvient de l'observable auquel nous nous référons à l'intérieur du sélecteur, et uniquement lors de leur modification, le gestionnaire est appelé.


Il est important de comprendre exactement comment mobx décide à quel observable nous souscrivons. Si j'ai écrit le sélecteur dans le code comme ceci () => app.store , alors la réaction ne sera jamais appelée, car le référentiel lui-même n'est pas observable, seuls ses champs le sont.


Si j'écrivais comme ceci () => app.store.keys , alors rien ne se reproduirait, car lors de l'ajout / suppression d'éléments du tableau, le lien vers celui-ci ne changera pas.


Pour la première fois, Mobx remplit la fonction de sélecteur et ne surveille que ceux observables auxquels nous avons accès. Cela se fait via des 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) } } } 

.


Les transactions


, : . 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} ); ... } 

, . - :




.


Conclusion


, , . .


, .


, siemarell

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


All Articles