编写安全的浏览器扩展


与常见的“客户端-服务器”架构相反,分散式应用程序的特征在于:


  • 无需使用用户登录名和密码存储数据库。 访问信息仅由用户自己存储,其真实性的确认在协议级别进行。
  • 无需使用服务器。 应用程序逻辑可以在可以存储所需数据量的区块链网络上执行。

有2个相对安全的用户密钥存储库-硬件钱包和浏览器扩展。 大多数硬件钱包都尽可能地安全,但是很难使用并且远非免费,但是浏览器扩展是安全性和易用性的完美结合,并且对于最终用户也可以完全免费。


考虑到所有这些,我们希望进行最安全的扩展,以简化分散式应用程序的开发,并提供用于处理事务和签名的简单API。
我们将在下面告诉您有关此体验的信息。


本文将提供有关如何编写浏览器扩展的分步说明,以及代码示例和屏幕截图。 您可以在存储库中找到所有代码。 每个提交在逻辑上对应于本文的一部分。


浏览器扩展的简要历史


浏览器扩展已经存在了一段时间。 在Internet Explorer中,它们分别于1999年和Firefox中(2004年)出现。 但是,很长一段时间以来,没有单一的扩展标准。


可以说,它与扩展程序一起出现在第四版的Google Chrome浏览器中。 当然,当时还没有规范,但正是Chrome API成为其基础:Chrome占领了浏览器市场的很大一部分并拥有内置的应用程序商店,实际上为浏览器扩展设定了标准。


Mozilla拥有自己的标准,但是,鉴于Chrome扩展程序的普及,该公司决定制作兼容的API。 2015年,在Mozilla的倡议下,万维网联盟(W3C)成立了一个特别小组,负责跨浏览器扩展的规范。


基于Chrome的现有API扩展。 这项工作得到了Microsoft的支持(Google拒绝参与该标准的开发),因此,出现了规范草案。


正式而言,Edge,Firefox和Opera支持该规范(请注意,Chrome不在此列表中)。 但实际上,该标准实际上与Chrome兼容,因为它实际上是基于扩展名编写的。 在此处阅读有关WebExtensions API的更多信息。


扩展结构


扩展名所需的唯一文件是清单(manifest.json)。 他是扩展的“入口点”。


清单


根据规范,清单文件是有效的JSON文件。 清单密钥的完整说明,以及有关在哪种浏览器中支持哪些密钥的信息,可以在此处找到。


可以忽略规范中未包含的键(Chrome和Firefox均报告错误,但扩展名仍可使用)。


我想提请注意几点。


  1. 背景 -一个包含以下字段的对象:
    1. 脚本 -将在后台上下文中执行的脚本数组(我们稍后再讨论);
    2. 页面 -您可以指定带有内容的html,而不是将在空白页面上执行的脚本。 在这种情况下,脚本字段将被忽略,并且脚本需要与内容一起插入到页面中。
    3. 持久 -二进制标志(如果未指定),当浏览器认为自己没有执行任何操作时,将“杀死”后台进程,并在必要时重新启动。 否则,仅当关闭浏览器时,页面才会被卸载。 Firefox不支持。
  2. content_scripts-对象数组,允许您将不同的脚本加载到不同的网页。 每个对象都包含以下重要字段:
    1. 匹配 -确定是否将包含特定内容脚本的url模式
    2. js-将被加载到该匹配中的脚本列表;
    3. exclude_matches-match字段中排除match该字段match URL。
  3. page_action-实际上,由对象负责浏览器中地址栏旁边显示的图标及其交互。 它还允许您显示弹出窗口,该窗口使用其HTML,CSS和JS进行设置。
    1. default_popup-具有弹出界面的HTML文件的路径,可能包含CSS和JS。
  4. 权限 -用于管理扩展程序权限的数组。 这里将详细介绍3种类型的权利
  5. web_accessible_resources-网页可以请求的扩展资源,例如图像,JS,CSS,HTML文件。
  6. externally_connectable-在这里您可以显式指定其他扩展名的ID以及可以连接的网页域。 域可以是第二级或更高级别。 在Firefox中不起作用。

执行上下文


该扩展具有三个代码执行上下文,即,该应用程序由三个部分组成,这些部分具有对浏览器API的不同访问级别。


扩展上下文


此处提供了大多数API。 在这种情况下,“实时”:


  1. 后台页面 -扩展的“后端”部分。 该文件在清单中通过“背景”键指示。
  2. 弹出页面 -单击扩展图标时出现的弹出页面 。 在清单中, browser_action > default_popup
  3. 自定义页面 -扩展页面,“居住”在chrome-extension://<id_>/customPage.html形式的单独标签中chrome-extension://<id_>/customPage.html

此上下文独立于浏览器窗口和选项卡而存在。 后台页面存在于单个副本中,并且始终有效(事件页面是例外,事件页面是在事件上启动后台脚本并在执行后死掉的)。 弹出页面在打开弹出窗口时存在,而“ 自定义”页面 -当带有它的选项卡打开时存在。 在此上下文中无法访问其他选项卡及其内容。


内容脚本上下文


内容脚本文件与每个浏览器选项卡一起启动。 他有权访问扩展API的一部分以及网页的DOM树。 内容脚本负责与页面进行交互。 操作DOM树的扩展可以在内容脚本中执行此操作,例如,广告阻止程序或翻译程序。 同样,内容脚本可以通过标准postMessage与页面进行通信。


网页上下文


这实际上是网页本身。 除非扩展清单中未明确指定此页面的域,否则它与扩展无关,并且在那里无法访问。


讯息传递


应用程序的不同部分必须彼此交换消息。 为此,有一个runtime.sendMessage API用于发送background消息,而tabs.sendMessage用于向页面(内容脚本,弹出窗口或网页,如果存在externally_connectable )发送消息。 以下是访问Chrome API的示例。


 //     JSON   const msg = {a: 'foo', b: 'bar'}; // extensionId   ,      ''  ( ui   ) chrome.runtime.sendMessage(extensionId, msg); //    chrome.runtime.onMessage.addListener((msg) => console.log(msg)) //       id chrome.tabs.sendMessage(tabId, msg) //      id , ,   chrome.tabs.query( {currentWindow: true, active : true}, function(tabArray){ tabArray.forEach(tab => console.log(tab.id)) } ) 

为了进行完整的通信,可以通过runtime.connect创建连接。 作为响应,我们获得runtime.Port ,当它打开时,您可以向其中发送任意数量的消息。 在客户端,例如contentscript ,它看起来像这样:


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

服务器或后台:


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

还有一个onDisconnect事件和一个disconnect方法。


应用概述


让我们做一个浏览器扩展,它存储私钥,提供对公共信息的访问(地址,公钥与页面进行通信,并允许第三方应用程序请求事务签名。


应用开发


我们的应用程序应与用户进行交互,并提供用于调用方法(例如,用于签署交易)的API页面。 它不能单独使用contentscript ,因为它只能访问DOM,而不能访问JS页面。 我们无法通过runtime.connect连接,因为所有域都需要API,并且清单中只能指定特定域。 结果,该方案将如下所示:



还有另一个脚本inpage ,我们将其注入到页面中。 它将在其上下文中运行,并提供用于扩展的API。


开始


所有浏览器扩展代码均可在GitHub上获得 。 在描述过程中,将有指向提交的链接。


让我们从清单开始:


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

创建空的background.js,popup.js,inpage.js和contentscript.js。 添加popup.html-我们的应用程序已经可以下载到Google Chrome中,并确保它可以运行。


为了验证这一点,您可以从此处获取代码。 除了我们所做的以外,还配置了链接以使用webpack构建项目。 要将应用程序添加到浏览器中,请使用chrome://扩展名,您需要选择load unpacked以及具有相应扩展名的文件夹-在我们的示例中为dist。



现在,我们的扩展程序已安装并运行。 您可以为不同的上下文运行开发者工具,如下所示:


弹出->



通过启动内容脚本的页面本身的控制台,可以访问内容脚本的控制台。


讯息传递


因此,我们需要建立两个通信渠道:inpage <->背景和弹出<->背景。 当然,您可以只向端口发送消息并发明您的协议,但是我更喜欢在开源metamask项目中侦听的方法。


这是一个用于以太坊网络的浏览器扩展。 在其中,应用程序的不同部分使用dnode库通过RPC进行通信。 如果您将nodejs流作为传输提供(意味着实现相同接口的对象),它可以让您快速方便地组织交换:


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

现在,我们将创建一个应用程序类。 它将为弹出窗口和网页创建API对象,并为它们创建dnode:


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

在下文中,我们使用extentionApi代替了全局的Chrome对象,它是指Google的浏览器中的Chrome和其他浏览器中的Chrome。 这样做是为了实现跨浏览器的兼容性,但是在本文的框架中可以仅使用chrome.runtime.connect。


在后台脚本中创建应用程序实例:


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

由于dnode可用于流,并且我们可以获取端口,因此需要适配器类。 它是使用可读流库制作的,该库在浏览器中实现了nodejs流:


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

现在在UI中创建一个连接:


 import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import Dnode from 'dnode/browser'; const DEV_MODE = process.env.NODE_ENV !== 'production'; setupUi().catch(console.error); async function setupUi(){ // ,       ,   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; } } 

然后,我们在内容脚本中创建一个连接:


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

由于我们不需要内容脚本中的API,而是直接在页面上,所以我们做两件事:


  1. 我们创建两个流。 一种是在postMessage顶部的页面。 为此,我们使用metamask的创建者提供的此程序包 。 第二个流在从runtime.connect接收到的端口的顶部到达后台。 点他们。 现在,该页面将流向后台。
  2. 将脚本注入DOM。 我们抽出脚本(清单中允许访问脚本)并创建一个script标签,其内容位于其中:

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

现在在inpage中创建一个api对象,然后全局启动它:


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

我们已经准备好使用单独的页面和UI API进行远程过程调用(RPC) 。 将新页面连接到后台时,我们可以看到以下内容:



空的API和来源。 在页面一侧,我们可以这样调用hello函数:



在现代JS中使用回调函数不是一个好主意,因此,我们将编写一个小的帮助程序来创建dnode,该dnode允许您将API传递给对象中的utils。


API对象现在将如下所示:


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

从远程获取对象,如下所示:


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

函数调用返回一个promise:



此处提供具有异步功能的版本。


通常,使用RPC和流的方法似乎非常灵活:我们可以使用蒸汽多路复用并为不同的任务创建几个不同的API。 原则上,dnode可以在任何地方使用,主要是将传输包装为nodejs流的形式。


JSON格式是一种替代方法,它实现了JSON RPC 2协议,但是它可用于特定的传输方式(TCP和HTTP(S)),在我们的情况下不适用。


内部状态和本地存储


我们将需要存储应用程序的内部状态-至少要存储用于签名的密钥。 我们可以在弹出式API中轻松地将状态添加到应用程序和更改状态的方法:


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

在后台,我们将所有内容包装在一个函数中,然后将应用程序对象写入window,以便您可以从控制台使用它:


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

从UI控制台添加一些键,然后查看状态发生了什么:



该状态必须是持久的,以便在重新启动时不会丢失密钥。


我们会将其存储在localStorage中,并随每次更改覆盖。 随后,用户界面也将需要访问它,我也想订阅所做的更改。 基于此,进行可观察的存储并订阅其更改将很方便。


我们将使用mobx库( https://github.com/mobxjs/mobx )。 因为我不必和她一起工作,所以选择权就落在了她身上,但是我真的很想研究她。


添加初始状态的初始化并使存储可观察:


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

mobx用“代理”代替了所有商店字段,并拦截了对它们的所有调用。 您可以订阅这些上诉。


此外,尽管这并不完全正确,但我经常会使用“根据变化”一词。 Mobx跟踪对字段的访问。 使用库创建的代理对象的getter和setter。


动作装饰器有两个作用:


  1. 在带有标志forceforceActions的严格模式下,mobx禁止直接更改状态。 在严格模式下工作被认为是一种好习惯。
  2. 即使函数多次更改状态(例如,我们将多个字段更改为几行代码),也只有在完成时才通知观察者。 这对于前端尤其重要,因为不必要的状态更新会导致不必要的元素呈现。 就我们而言,第一个和第二个都没有特别的关系,但是,我们将遵循最佳实践。 装饰者决定保留所有可更改观察字段状态的功能。

在后台,添加初始化并将状态保存在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) } } } 

这里的反应功能很有趣。 她有两个论点:


  1. 数据选择器。
  2. 每次更改时都会用此数据调用的处理程序。

与redux不同,在redux中,我们明确地将状态作为参数,mobx会记住选择器内部所引用的可观察对象,并且只有在更改它们时才调用处理程序。


准确了解mobx如何确定我们要订阅的可观察性非常重要。 如果我在类似() => app.store的代码中编写了一个选择器,那么该反应将永远不会被调用,因为存储库本身是不可观察的,只有它的字段是这样。


如果我这样写() => app.store.keys ,那么什么也不会发生,因为添加/删除数组的元素时,指向它的链接不会改变。


Mobx首次执行选择器的功能,并且仅监视我们有权访问的可观察者。 这是通过代理获取器完成的。 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) } } } 

.


交易次数


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

, . - :




.


结论


, , . .


, .


, siemarell

Source: https://habr.com/ru/post/zh-CN451796/


All Articles