
与常见的“客户端-服务器”架构相反,分散式应用程序的特征在于:
- 无需使用用户登录名和密码存储数据库。 访问信息仅由用户自己存储,其真实性的确认在协议级别进行。
- 无需使用服务器。 应用程序逻辑可以在可以存储所需数据量的区块链网络上执行。
有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均报告错误,但扩展名仍可使用)。
我想提请注意几点。
- 背景 -一个包含以下字段的对象:
- 脚本 -将在后台上下文中执行的脚本数组(我们稍后再讨论);
- 页面 -您可以指定带有内容的html,而不是将在空白页面上执行的脚本。 在这种情况下,脚本字段将被忽略,并且脚本需要与内容一起插入到页面中。
- 持久 -二进制标志(如果未指定),当浏览器认为自己没有执行任何操作时,将“杀死”后台进程,并在必要时重新启动。 否则,仅当关闭浏览器时,页面才会被卸载。 Firefox不支持。
- content_scripts-对象数组,允许您将不同的脚本加载到不同的网页。 每个对象都包含以下重要字段:
- 匹配 -确定是否将包含特定内容脚本的url模式 。
- js-将被加载到该匹配中的脚本列表;
- exclude_matches-从
match
字段中排除match
该字段match
URL。
- page_action-实际上,由对象负责浏览器中地址栏旁边显示的图标及其交互。 它还允许您显示弹出窗口,该窗口使用其HTML,CSS和JS进行设置。
- default_popup-具有弹出界面的HTML文件的路径,可能包含CSS和JS。
- 权限 -用于管理扩展程序权限的数组。 这里将详细介绍3种类型的权利。
- web_accessible_resources-网页可以请求的扩展资源,例如图像,JS,CSS,HTML文件。
- externally_connectable-在这里您可以显式指定其他扩展名的ID以及可以连接的网页域。 域可以是第二级或更高级别。 在Firefox中不起作用。
执行上下文
该扩展具有三个代码执行上下文,即,该应用程序由三个部分组成,这些部分具有对浏览器API的不同访问级别。
扩展上下文
此处提供了大多数API。 在这种情况下,“实时”:
- 后台页面 -扩展的“后端”部分。 该文件在清单中通过“背景”键指示。
- 弹出页面 -单击扩展图标时出现的弹出页面 。 在清单中,
browser_action
> default_popup
。 - 自定义页面 -扩展页面,“居住”在
chrome-extension://<id_>/customPage.html
形式的单独标签中chrome-extension://<id_>/customPage.html
。
此上下文独立于浏览器窗口和选项卡而存在。 后台页面存在于单个副本中,并且始终有效(事件页面是例外,事件页面是在事件上启动后台脚本并在执行后死掉的)。 弹出页面在打开弹出窗口时存在,而“ 自定义”页面 -当带有它的选项卡打开时存在。 在此上下文中无法访问其他选项卡及其内容。
内容脚本上下文
内容脚本文件与每个浏览器选项卡一起启动。 他有权访问扩展API的一部分以及网页的DOM树。 内容脚本负责与页面进行交互。 操作DOM树的扩展可以在内容脚本中执行此操作,例如,广告阻止程序或翻译程序。 同样,内容脚本可以通过标准postMessage
与页面进行通信。
网页上下文
这实际上是网页本身。 除非扩展清单中未明确指定此页面的域,否则它与扩展无关,并且在那里无法访问。
讯息传递
应用程序的不同部分必须彼此交换消息。 为此,有一个runtime.sendMessage
API用于发送background
消息,而tabs.sendMessage
用于向页面(内容脚本,弹出窗口或网页,如果存在externally_connectable
)发送消息。 以下是访问Chrome API的示例。
为了进行完整的通信,可以通过runtime.connect
创建连接。 作为响应,我们获得runtime.Port
,当它打开时,您可以向其中发送任意数量的消息。 在客户端,例如contentscript
,它看起来像这样:
服务器或后台:
还有一个onDisconnect
事件和一个disconnect
方法。
应用概述
让我们做一个浏览器扩展,它存储私钥,提供对公共信息的访问(地址,公钥与页面进行通信,并允许第三方应用程序请求事务签名。
应用开发
我们的应用程序应与用户进行交互,并提供用于调用方法(例如,用于签署交易)的API页面。 它不能单独使用contentscript
,因为它只能访问DOM,而不能访问JS页面。 我们无法通过runtime.connect
连接,因为所有域都需要API,并且清单中只能指定特定域。 结果,该方案将如下所示:

还有另一个脚本inpage
,我们将其注入到页面中。 它将在其上下文中运行,并提供用于扩展的API。
开始
所有浏览器扩展代码均可在GitHub上获得 。 在描述过程中,将有指向提交的链接。
让我们从清单开始:
{
创建空的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";
现在,我们将创建一个应用程序类。 它将为弹出窗口和网页创建API对象,并为它们创建dnode:
import Dnode from 'dnode/browser'; export class SignerApp {
在下文中,我们使用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();
由于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(){
然后,我们在内容脚本中创建一个连接:
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 {
由于我们不需要内容脚本中的API,而是直接在页面上,所以我们做两件事:
- 我们创建两个流。 一种是在postMessage顶部的页面。 为此,我们使用metamask的创建者提供的此程序包 。 第二个流在从
runtime.connect
接收到的端口的顶部到达后台。 点他们。 现在,该页面将流向后台。 - 将脚本注入DOM。 我们抽出脚本(清单中允许访问脚本)并创建一个
script
标签,其内容位于其中:
import PostMessageStream from 'post-message-stream'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; setupConnection(); injectScript(); function setupConnection(){
现在在inpage中创建一个api对象,然后全局启动它:
import PostMessageStream from 'post-message-stream'; import Dnode from 'dnode/browser'; setupInpageApi().catch(console.error); async function setupInpageApi() {
我们已经准备好使用单独的页面和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 => {
函数调用返回一个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 = {}) {
mobx用“代理”代替了所有商店字段,并拦截了对它们的所有调用。 您可以订阅这些上诉。
此外,尽管这并不完全正确,但我经常会使用“根据变化”一词。 Mobx跟踪对字段的访问。 使用库创建的代理对象的getter和setter。
动作装饰器有两个作用:
- 在带有标志forceforceActions的严格模式下,mobx禁止直接更改状态。 在严格模式下工作被认为是一种好习惯。
- 即使函数多次更改状态(例如,我们将多个字段更改为几行代码),也只有在完成时才通知观察者。 这对于前端尤其重要,因为不必要的状态更新会导致不必要的元素呈现。 就我们而言,第一个和第二个都没有特别的关系,但是,我们将遵循最佳实践。 装饰者决定保留所有可更改观察字段状态的功能。
在后台,添加初始化并将状态保存在localStorage中:
import {reaction, toJS} from 'mobx'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {SignerApp} from "./SignerApp";
这里的反应功能很有趣。 她有两个论点:
- 数据选择器。
- 每次更改时都会用此数据调用的处理程序。
与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";
. . 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; }
.
交易次数
, : . 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() { ...
, . - :


.
结论
, , . .
, .
, siemarell