เมื่อคำนึงถึงทั้งหมดนี้ เราต้องการสร้างส่วนขยายที่ปลอดภัยที่สุดที่ช่วยลดความยุ่งยากในการพัฒนาแอปพลิเคชันแบบกระจายอำนาจโดยการจัดเตรียม API แบบง่ายสำหรับการทำงานกับธุรกรรมและลายเซ็น
เราจะเล่าให้คุณฟังเกี่ยวกับประสบการณ์นี้ด้านล่าง
ส่วนขยายเบราว์เซอร์มีมานานแล้ว ปรากฏใน Internet Explorer ย้อนกลับไปในปี 1999 ใน Firefox ในปี 2004 อย่างไรก็ตาม เป็นเวลานานมากแล้วที่ไม่มีมาตรฐานเดียวสำหรับการขยายเวลา
เราสามารถพูดได้ว่ามันปรากฏพร้อมกับส่วนขยายใน Google Chrome เวอร์ชันที่สี่ แน่นอนว่าไม่มีข้อกำหนดในตอนนั้น แต่เป็น Chrome API ที่กลายเป็นพื้นฐาน: หลังจากเอาชนะตลาดเบราว์เซอร์ส่วนใหญ่และมีร้านค้าแอปพลิเคชันในตัว Chrome ได้สร้างมาตรฐานสำหรับส่วนขยายเบราว์เซอร์อย่างแท้จริง
Mozilla มีมาตรฐานเป็นของตัวเอง แต่เมื่อเห็นความนิยมของส่วนขยาย Chrome บริษัทจึงตัดสินใจสร้าง API ที่เข้ากันได้ ในปี 2015 ตามความคิดริเริ่มของ Mozilla กลุ่มพิเศษได้ถูกสร้างขึ้นภายใน World Wide Web Consortium (W3C) เพื่อทำงานเกี่ยวกับข้อกำหนดส่วนขยายข้ามเบราว์เซอร์
ส่วนขยาย API ที่มีอยู่สำหรับ Chrome ถูกนำมาใช้เป็นพื้นฐาน งานนี้ดำเนินการโดยได้รับการสนับสนุนจาก Microsoft (Google ปฏิเสธที่จะเข้าร่วมในการพัฒนามาตรฐาน) และผลที่ตามมาก็มีร่างปรากฏขึ้น ข้อกำหนด.
อย่างเป็นทางการ ข้อกำหนดนี้รองรับโดย Edge, Firefox และ Opera (โปรดทราบว่า Chrome ไม่อยู่ในรายการนี้) แต่ในความเป็นจริงแล้ว มาตรฐานนี้เข้ากันได้กับ Chrome เป็นส่วนใหญ่ เนื่องจากจริงๆ แล้วมันถูกเขียนขึ้นตามส่วนขยายของมัน คุณสามารถอ่านเพิ่มเติมเกี่ยวกับ WebExtensions API ที่นี่.
ไฟล์สคริปต์เนื้อหาจะเปิดขึ้นพร้อมกับแต่ละแท็บเบราว์เซอร์ มีสิทธิ์เข้าถึงส่วนหนึ่งของ API ของส่วนขยายและแผนผัง DOM ของหน้าเว็บ เป็นสคริปต์เนื้อหาที่รับผิดชอบในการโต้ตอบกับเพจ ส่วนขยายที่จัดการโครงสร้าง DOM จะทำเช่นนี้ในสคริปต์เนื้อหา เช่น ตัวบล็อกโฆษณาหรือตัวแปล นอกจากนี้ สคริปต์เนื้อหายังสามารถสื่อสารกับเพจผ่านมาตรฐานได้ postMessage.
ส่วนต่าง ๆ ของแอปพลิเคชันจะต้องแลกเปลี่ยนข้อความระหว่างกัน มี API สำหรับสิ่งนี้ runtime.sendMessage เพื่อส่งข้อความ 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))
}
)
// Опять же 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) {
...
});
{
// Имя и описание, версия. Все это будет видно в браузере в 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"]
}
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)
})
}
}
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)
}
}
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 ที่ไม่ได้อยู่ในสคริปต์เนื้อหา แต่ต้องการบนหน้าโดยตรง เราจึงทำสองสิ่ง:
import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";
const pageApi = await new Promise(resolve => {
dnode.once('remote', remoteApi => {
// С помощью утилит меняем все callback на promise
resolve(transformMethods(cbToPromise, remoteApi))
})
});
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)
}
...
}
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)
}
}
}
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)
}
เบราว์เซอร์มี API ที่ไม่ได้ใช้งานซึ่งคุณสามารถสมัครรับเหตุการณ์ - การเปลี่ยนแปลงสถานะได้ รัฐตามนั้นก็ได้ idle, active и 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)
}
}
}
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>
}
}