การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

แตกต่างจากสถาปัตยกรรม “ไคลเอนต์-เซิร์ฟเวอร์” ทั่วไป แอปพลิเคชันแบบกระจายอำนาจมีลักษณะดังนี้:

  • ไม่จำเป็นต้องจัดเก็บฐานข้อมูลด้วยการเข้าสู่ระบบของผู้ใช้และรหัสผ่าน ข้อมูลการเข้าถึงจะถูกจัดเก็บโดยผู้ใช้เองเท่านั้น และการยืนยันความถูกต้องจะเกิดขึ้นในระดับโปรโตคอล
  • ไม่จำเป็นต้องใช้เซิร์ฟเวอร์ ตรรกะของแอปพลิเคชันสามารถดำเนินการได้บนเครือข่ายบล็อกเชน ซึ่งสามารถจัดเก็บข้อมูลตามจำนวนที่ต้องการได้

มีที่เก็บข้อมูลที่ค่อนข้างปลอดภัย 2 แห่งสำหรับคีย์ผู้ใช้ - กระเป๋าเงินฮาร์ดแวร์และส่วนขยายเบราว์เซอร์ กระเป๋าสตางค์ฮาร์ดแวร์ส่วนใหญ่มีความปลอดภัยสูง แต่ก็ใช้งานยากและห่างไกลจากความฟรี แต่ส่วนขยายเบราว์เซอร์เป็นส่วนผสมที่ลงตัวระหว่างความปลอดภัยและความสะดวกในการใช้งาน และยังสามารถให้บริการฟรีสำหรับผู้ใช้ปลายทางอีกด้วย

เมื่อคำนึงถึงทั้งหมดนี้ เราต้องการสร้างส่วนขยายที่ปลอดภัยที่สุดที่ช่วยลดความยุ่งยากในการพัฒนาแอปพลิเคชันแบบกระจายอำนาจโดยการจัดเตรียม 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 ที่นี่.

โครงสร้างส่วนขยาย

ไฟล์เดียวที่จำเป็นสำหรับส่วนขยายคือไฟล์ Manifest (manifest.json) นอกจากนี้ยังเป็น "จุดเริ่มต้น" ของการขยายตัวอีกด้วย

การประกาศ

ตามข้อกำหนด ไฟล์ Manifest เป็นไฟล์ JSON ที่ถูกต้อง คำอธิบายแบบเต็มของคีย์รายการพร้อมข้อมูลเกี่ยวกับคีย์ที่รองรับในเบราว์เซอร์ที่สามารถดูได้ ที่นี่.

คีย์ที่ไม่อยู่ในข้อกำหนด "อาจถูกละเว้น" (ทั้งรายงานข้อผิดพลาดของ Chrome และ Firefox แต่ส่วนขยายยังคงทำงานต่อไป)

และฉันอยากจะดึงความสนใจไปในบางประเด็น

  1. พื้นหลัง — วัตถุที่มีฟิลด์ต่อไปนี้:
    1. สคริปต์ — อาร์เรย์ของสคริปต์ที่จะดำเนินการในบริบทเบื้องหลัง (เราจะพูดถึงเรื่องนี้ในภายหลัง)
    2. หน้า - แทนที่จะใช้สคริปต์ที่จะดำเนินการในหน้าว่าง คุณสามารถระบุ html พร้อมเนื้อหาได้ ในกรณีนี้ ฟิลด์สคริปต์จะถูกละเว้น และจะต้องแทรกสคริปต์ลงในหน้าเนื้อหา
    3. หมั่น — ธงไบนารี หากไม่ได้ระบุ เบราว์เซอร์จะ "หยุด" กระบวนการเบื้องหลังเมื่อพิจารณาว่าไม่ได้ทำอะไรเลย และรีสตาร์ทหากจำเป็น มิฉะนั้น หน้าจะถูกยกเลิกการโหลดเมื่อปิดเบราว์เซอร์เท่านั้น ไม่รองรับใน Firefox
  2. content_scripts — อาร์เรย์ของออบเจ็กต์ที่ให้คุณโหลดสคริปต์ที่แตกต่างกันไปยังหน้าเว็บต่างๆ แต่ละออบเจ็กต์ประกอบด้วยช่องสำคัญต่อไปนี้:
    1. ที่ตรงกัน - URL รูปแบบซึ่งกำหนดว่าจะรวมสคริปต์เนื้อหาใดสคริปต์หนึ่งไว้หรือไม่
    2. js — รายการสคริปต์ที่จะโหลดเข้าสู่แมตช์นี้
    3. ไม่รวม_ตรงกัน - แยกออกจากสนาม match URL ที่ตรงกับฟิลด์นี้
  3. หน้า_การกระทำ - จริงๆ แล้วเป็นวัตถุที่รับผิดชอบไอคอนที่แสดงถัดจากแถบที่อยู่ในเบราว์เซอร์และโต้ตอบกับมัน นอกจากนี้ยังช่วยให้คุณแสดงหน้าต่างป๊อปอัปซึ่งกำหนดโดยใช้ HTML, CSS และ JS ของคุณเอง
    1. default_popup — เส้นทางไปยังไฟล์ HTML พร้อมอินเทอร์เฟซป๊อปอัป อาจมี CSS และ JS
  4. สิทธิ์ — อาร์เรย์สำหรับจัดการสิทธิ์ส่วนขยาย สิทธิมี 3 ประเภท ซึ่งมีรายละเอียดอธิบายไว้แล้ว ที่นี่
  5. web_accessible_resources — ทรัพยากรส่วนขยายที่หน้าเว็บสามารถขอได้ เช่น รูปภาพ, ไฟล์ JS, CSS, HTML
  6. ภายนอก_เชื่อมต่อได้ — ที่นี่คุณสามารถระบุ ID ของส่วนขยายและโดเมนอื่นๆ ของหน้าเว็บที่คุณสามารถเชื่อมต่อได้อย่างชัดเจน โดเมนสามารถเป็นระดับที่สองหรือสูงกว่าได้ ไม่ทำงานใน Firefox

บริบทการดำเนินการ

ส่วนขยายมีบริบทการเรียกใช้โค้ดสามบริบท กล่าวคือ แอปพลิเคชันประกอบด้วยสามส่วนซึ่งมีระดับการเข้าถึง API ของเบราว์เซอร์ที่แตกต่างกัน

บริบทส่วนขยาย

API ส่วนใหญ่มีอยู่ที่นี่ ในบริบทนี้ พวกเขา "มีชีวิตอยู่":

  1. หน้าพื้นหลัง — ส่วน “แบ็กเอนด์” ของส่วนขยาย ไฟล์ถูกระบุในรายการโดยใช้ปุ่ม "พื้นหลัง"
  2. หน้าป๊อปอัป — หน้าป๊อปอัพที่ปรากฏขึ้นเมื่อคุณคลิกที่ไอคอนส่วนขยาย ในแถลงการณ์ browser_action -> default_popup.
  3. หน้ากำหนดเอง — หน้าส่วนขยาย “ชีวิต” ในแท็บแยกต่างหากของมุมมอง chrome-extension://<id_расширения>/customPage.html.

บริบทนี้มีอยู่โดยไม่ขึ้นอยู่กับหน้าต่างและแท็บของเบราว์เซอร์ หน้าพื้นหลัง มีอยู่ในสำเนาเดียวและใช้งานได้เสมอ (ข้อยกเว้นคือหน้ากิจกรรม เมื่อเหตุการณ์เรียกใช้สคริปต์พื้นหลังและ "ตาย" หลังจากดำเนินการ) หน้าป๊อปอัป มีอยู่เมื่อหน้าต่างป๊อปอัปเปิดขึ้นและ หน้ากำหนดเอง — ขณะที่แท็บที่เปิดอยู่ ไม่มีการเข้าถึงแท็บอื่นและเนื้อหาจากบริบทนี้

บริบทสคริปต์เนื้อหา

ไฟล์สคริปต์เนื้อหาจะเปิดขึ้นพร้อมกับแต่ละแท็บเบราว์เซอร์ มีสิทธิ์เข้าถึงส่วนหนึ่งของ 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))
    }
)

เพื่อการสื่อสารที่สมบูรณ์คุณสามารถสร้างการเชื่อมต่อผ่าน 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://extensions คุณต้องเลือกโหลดที่คลายการบีบอัดและโฟลเดอร์ที่มีส่วนขยายที่เกี่ยวข้อง - ในกรณีของเรา dist

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

ตอนนี้ส่วนขยายของเราได้รับการติดตั้งและใช้งานได้แล้ว คุณสามารถเรียกใช้เครื่องมือสำหรับนักพัฒนาสำหรับบริบทที่แตกต่างกันได้ดังนี้:

ป๊อปอัป ->

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

การเข้าถึงคอนโซลสคริปต์เนื้อหาดำเนินการผ่านคอนโซลของเพจที่เปิดใช้งานการเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

แลกเปลี่ยนข้อความ

ดังนั้นเราจึงจำเป็นต้องสร้างช่องทางการสื่อสารสองช่องทาง: พื้นหลังในเพจ <-> และพื้นหลังป๊อปอัป <-> แน่นอนคุณสามารถส่งข้อความไปยังพอร์ตและสร้างโปรโตคอลของคุณเองได้ แต่ฉันชอบแนวทางที่ฉันเห็นในโครงการโอเพ่นซอร์ส metamask มากกว่า

นี่คือส่วนขยายเบราว์เซอร์สำหรับการทำงานกับเครือข่าย Ethereum ในนั้น ส่วนต่างๆ ของแอปพลิเคชันจะสื่อสารผ่าน RPC โดยใช้ไลบรารี dnode ช่วยให้คุณสามารถจัดระเบียบการแลกเปลี่ยนได้อย่างรวดเร็วและสะดวกหากคุณจัดเตรียมสตรีม 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)
        })
    }
}

ที่นี่และด้านล่าง แทนที่จะเป็นวัตถุ Chrome ทั่วโลก เราใช้ extensionApi ซึ่งเข้าถึง Chrome ในเบราว์เซอร์ของ Google และเบราว์เซอร์ในเบราว์เซอร์อื่น ๆ สิ่งนี้ทำเพื่อความเข้ากันได้ข้ามเบราว์เซอร์ แต่สำหรับวัตถุประสงค์ของบทความนี้ เราสามารถใช้ '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. เราสร้างสองกระแส หนึ่ง - ไปทางหน้า ด้านบนของโพสต์ข้อความ สำหรับสิ่งนี้เราใช้สิ่งนี้ แพ็คเกจนี้ จากผู้สร้าง 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);
    }
}

ตอนนี้เราสร้างวัตถุ api ใน inpage และตั้งค่าเป็นสากล:

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

พวกเราพร้อมแล้ว Remote Procedure Call (RPC) พร้อม API แยกต่างหากสำหรับเพจและ UI. เมื่อเชื่อมต่อเพจใหม่เข้ากับพื้นหลัง เราจะเห็นสิ่งนี้:

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

API และต้นทางว่างเปล่า ในหน้าเพจ เราสามารถเรียกใช้ฟังก์ชัน hello ได้ดังนี้:

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

การทำงานกับฟังก์ชันการโทรกลับใน JS สมัยใหม่ถือเป็นมารยาทที่ไม่ดี ดังนั้นเรามาเขียนตัวช่วยเล็กๆ เพื่อสร้าง 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))
    })
});

และการเรียกใช้ฟังก์ชันส่งคืนสัญญา:

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

มีเวอร์ชันพร้อมฟังก์ชันอะซิงโครนัส ที่นี่.

โดยรวมแล้ว วิธี RPC และการสตรีมดูค่อนข้างยืดหยุ่น: เราสามารถใช้ Steam Multiplexing และสร้าง API ต่างๆ สำหรับงานที่แตกต่างกันได้ โดยหลักการแล้ว dnode สามารถใช้ได้ทุกที่ สิ่งสำคัญคือการห่อการขนส่งในรูปแบบของสตรีม nodejs

อีกทางเลือกหนึ่งคือรูปแบบ JSON ซึ่งใช้โปรโตคอล JSON RPC 2 อย่างไรก็ตาม รูปแบบนี้จะใช้ได้กับการขนส่งเฉพาะ (TCP และ HTTP(S)) ซึ่งใช้ไม่ได้ในกรณีของเรา

สถานะภายในและ localStorage

เราจะต้องจัดเก็บสถานะภายในของแอปพลิเคชัน - อย่างน้อยก็คีย์การลงนาม เราสามารถเพิ่มสถานะให้กับแอปพลิเคชันและวิธีการเปลี่ยนแปลงในป๊อปอัป 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)
        }
    }

    ...

} 

ในพื้นหลัง เราจะรวมทุกอย่างไว้ในฟังก์ชันและเขียนวัตถุแอปพลิเคชันไปที่หน้าต่าง เพื่อให้เราสามารถทำงานกับมันจากคอนโซล:

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 และเขียนทับทุกการเปลี่ยนแปลง ต่อจากนั้น การเข้าถึง UI ก็จำเป็นเช่นกัน และฉันก็อยากจะสมัครรับการเปลี่ยนแปลงด้วย ด้วยเหตุนี้ จะสะดวกในการสร้างพื้นที่เก็บข้อมูลที่สังเกตได้และสมัครรับการเปลี่ยนแปลง

เราจะใช้ไลบรารี 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 ติดตามการเข้าถึงฟิลด์ มีการใช้ตัวรับและผู้ตั้งค่าของออบเจ็กต์พร็อกซีที่ไลบรารีสร้างขึ้น

การตกแต่งแอ็คชั่นมีจุดประสงค์สองประการ:

  1. ในโหมดเข้มงวดพร้อมกับแฟล็ก enforceActions 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 ที่เราได้รับสถานะเป็นอาร์กิวเมนต์อย่างชัดเจน mobx จะจดจำสิ่งที่สังเกตได้ที่เราเข้าถึงภายในตัวเลือก และเรียกตัวจัดการเฉพาะเมื่อมีการเปลี่ยนแปลงเท่านั้น

สิ่งสำคัญคือต้องเข้าใจให้แน่ชัดว่า mobx ตัดสินใจได้อย่างไรว่าเราสมัครรับข้อมูลใดบ้าง ถ้าฉันเขียนตัวเลือกในโค้ดแบบนี้() => app.storeจากนั้นปฏิกิริยาจะไม่ถูกเรียก เนื่องจากพื้นที่เก็บข้อมูลนั้นไม่สามารถสังเกตได้ มีเพียงช่องข้อมูลเท่านั้นที่สังเกตได้

ถ้าผมจะเขียนแบบนี้ () => app.store.keysจากนั้นจะไม่มีอะไรเกิดขึ้นอีก เนื่องจากเมื่อเพิ่ม/ลบองค์ประกอบอาร์เรย์ การอ้างอิงถึงองค์ประกอบนั้นจะไม่เปลี่ยนแปลง

Mobx ทำหน้าที่เป็นตัวเลือกเป็นครั้งแรกและติดตามเฉพาะสิ่งที่สังเกตได้ที่เราเข้าถึงเท่านั้น สิ่งนี้ทำได้ผ่านตัวรับพรอกซี ดังนั้นจึงใช้ฟังก์ชันในตัวที่นี่ toJS. มันจะส่งคืนออบเจ็กต์ใหม่พร้อมพรอกซีทั้งหมดแทนที่ด้วยฟิลด์ดั้งเดิม ในระหว่างการดำเนินการ มันจะอ่านฟิลด์ทั้งหมดของอ็อบเจ็กต์ - ด้วยเหตุนี้ตัวรับจึงถูกทริกเกอร์

ในคอนโซลป๊อปอัปเราจะเพิ่มคีย์หลายอันอีกครั้ง คราวนี้พวกเขายังลงเอยใน localStorage ด้วย:

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

เมื่อโหลดหน้าพื้นหลังใหม่ ข้อมูลจะยังคงอยู่ที่เดิม

สามารถดูรหัสแอปพลิเคชันทั้งหมดจนถึงจุดนี้ได้ ที่นี่.

การจัดเก็บคีย์ส่วนตัวอย่างปลอดภัย

การจัดเก็บคีย์ส่วนตัวในรูปแบบข้อความที่ชัดเจนนั้นไม่ปลอดภัย: มีโอกาสเสมอที่คุณจะถูกแฮ็ก เข้าถึงคอมพิวเตอร์ของคุณ และอื่นๆ ดังนั้นใน localStorage เราจะจัดเก็บคีย์ในรูปแบบที่เข้ารหัสด้วยรหัสผ่าน

เพื่อความปลอดภัยที่มากขึ้น เราจะเพิ่มสถานะล็อคให้กับแอปพลิเคชัน ซึ่งจะไม่สามารถเข้าถึงคีย์ได้เลย เราจะโอนส่วนขยายไปยังสถานะล็อคโดยอัตโนมัติเนื่องจากการหมดเวลา

Mobx ช่วยให้คุณจัดเก็บชุดข้อมูลขั้นต่ำเท่านั้น และส่วนที่เหลือจะถูกคำนวณโดยอัตโนมัติ สิ่งเหล่านี้เรียกว่าคุณสมบัติการคำนวณ สามารถเปรียบเทียบกับมุมมองในฐานข้อมูลได้:

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

ตอนนี้เราจัดเก็บเฉพาะคีย์และรหัสผ่านที่เข้ารหัสเท่านั้น ทุกอย่างอื่นจะถูกคำนวณ เราทำการถ่ายโอนไปสู่สถานะที่ถูกล็อคโดยการลบรหัสผ่านออกจากสถานะ ขณะนี้ API สาธารณะมีวิธีในการเริ่มต้นพื้นที่จัดเก็บข้อมูล

เขียนขึ้นเพื่อการเข้ารหัส ยูทิลิตี้ที่ใช้ crypto-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)
}

เบราว์เซอร์มี 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)
        }
    }
}

รหัสก่อนขั้นตอนนี้คือ ที่นี่.

การทำธุรกรรม

ดังนั้นเราจึงมาถึงสิ่งที่สำคัญที่สุด: การสร้างและการลงนามธุรกรรมบนบล็อคเชน เราจะใช้บล็อกเชนและไลบรารีของ WAVES ธุรกรรมคลื่น.

ขั้นแรก เรามาเพิ่มอาร์เรย์ของข้อความที่ต้องลงนามในสถานะ จากนั้นเพิ่มวิธีการเพิ่มข้อความใหม่ ยืนยันลายเซ็น และการปฏิเสธ:

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 จะทำเองเมื่อเพิ่มข้อความลงในอาร์เรย์ อย่างไรก็ตาม มันจะสร้างออบเจ็กต์ใหม่ซึ่งเราจะไม่มีการอ้างอิง แต่เราจะต้องใช้สำหรับขั้นตอนต่อไป

ต่อไป เราจะคืนสัญญาที่จะแก้ไขเมื่อสถานะข้อความเปลี่ยนแปลง สถานะจะถูกติดตามโดยปฏิกิริยา ซึ่งจะ "ฆ่าตัวเอง" เมื่อสถานะเปลี่ยนไป

รหัสวิธีการ approve и reject ง่ายมาก: เราเพียงแค่เปลี่ยนสถานะของข้อความ หลังจากลงนามแล้ว หากจำเป็น

เราใส่การอนุมัติและปฏิเสธใน UI API, 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 ที่ได้รับจากพื้นหลัง:

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

ในตอนท้ายเราเริ่มเรนเดอร์อินเทอร์เฟซของแอปพลิเคชัน นี่คือแอปพลิเคชั่นตอบโต้ วัตถุพื้นหลังถูกส่งผ่านโดยใช้อุปกรณ์ประกอบฉาก แน่นอนว่าคงจะถูกต้องถ้าจะให้บริการแยกต่างหากสำหรับวิธีการและร้านค้าสำหรับรัฐ แต่สำหรับวัตถุประสงค์ของบทความนี้ก็เพียงพอแล้ว:

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 มันง่ายมากที่จะเริ่มเรนเดอร์เมื่อข้อมูลเปลี่ยนแปลง เราเพียงแค่แขวนมัณฑนากรผู้สังเกตการณ์ออกจากแพ็คเกจ mobx-react บนส่วนประกอบและการเรนเดอร์จะถูกเรียกโดยอัตโนมัติเมื่อสิ่งที่สังเกตได้ซึ่งอ้างอิงโดยส่วนประกอบเปลี่ยนแปลง คุณไม่จำเป็นต้องมี mapStateToProps หรือเชื่อมต่อเหมือนใน 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}
    );

...
}

ดังนั้นใบสมัครจึงพร้อมแล้ว หน้าเว็บอาจขอลายเซ็นสำหรับการทำธุรกรรม:

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

การเขียนส่วนขยายเบราว์เซอร์ที่ปลอดภัย

รหัสมีอยู่ที่นี่ ลิงค์.

ข้อสรุป

หากอ่านบทความจบแล้วแต่ยังมีข้อสงสัยสามารถสอบถามได้ที่ ที่เก็บที่มีนามสกุล. คุณยังจะพบการคอมมิตสำหรับแต่ละขั้นตอนที่กำหนดอีกด้วย

และหากคุณสนใจที่จะดูโค้ดสำหรับส่วนขยายจริง คุณจะพบสิ่งนี้ ที่นี่.

รหัส พื้นที่เก็บข้อมูล และรายละเอียดงานจาก ซีมาเรล

ที่มา: will.com

เพิ่มความคิดเห็น