
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.
- background - un objet qui comprend les champs suivants:
- scripts - un tableau de scripts qui seront exécutés en arrière-plan (nous en parlerons un peu plus tard);
- 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;
- 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.
- 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:
- matches - modèle d'URL par lequel il est déterminé si un script de contenu spécifique sera inclus ou non.
- js - une liste de scripts qui seront chargés dans cette correspondance;
- exclude_matches - exclut les URL de
match
champ de correspondance qui correspondent à ce champ.
- 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.
- default_popup - chemin vers le fichier HTML avec une interface popup, peut contenir CSS et JS.
- autorisations - un tableau pour gérer les droits d'extension. Il existe 3 types de droits qui sont décrits en détail ici.
- web_accessible_resources - ressources d'extension qu'une page Web peut demander, par exemple, des images, JS, CSS, fichiers HTML.
- 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":
- Page de fond - partie «backend» de l'extension. Le fichier est indiqué dans le manifeste par la touche «background».
- Page contextuelle - page contextuelle qui apparaît lorsque vous cliquez sur l'icône d'extension. Dans le manifeste,
browser_action
-> default_popup
. - 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.
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:
Serveur ou arrière-plan:
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:
{
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";
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 {
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();
É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(){
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 {
Comme nous n'avons pas besoin de l'API dans le script de contenu, mais directement sur la page, nous faisons deux choses:
- 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. - 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(){
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() {
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 => {
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 = {}) {
"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:
- 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.
- 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";
La fonction de réaction est intéressante ici. Elle a deux arguments:
- Sélecteur de données.
- 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";
. . 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; }
.
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) {
, 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() { ...
, . - :


.
Conclusion
, , . .
, .
, siemarell