كتابة ملحق متصفح آمن

كتابة ملحق متصفح آمن

على عكس بنية "العميل-الخادم" الشائعة ، تتميز التطبيقات اللامركزية بما يلي:

  • لا حاجة لتخزين قاعدة بيانات بأسماء المستخدمين وكلمات المرور. يتم تخزين معلومات الوصول حصريًا من قبل المستخدمين أنفسهم ، ويتم تأكيد صحتها على مستوى البروتوكول.
  • لا حاجة لاستخدام الخادم. يمكن تنفيذ منطق التطبيق في شبكة blockchain ، حيث يمكن تخزين الكمية المطلوبة من البيانات.

يوجد مستودعين آمنين نسبيًا لمفاتيح المستخدم - محافظ الأجهزة وملحقات المستعرض. في حين أن محافظ الأجهزة آمنة في الغالب ولكن يصعب استخدامها وبعيدة عن كونها مجانية ، فإن ملحقات المستعرض هي مزيج مثالي من الأمان وسهولة الاستخدام ، ويمكن أن تكون مجانية تمامًا للمستخدمين النهائيين.

بالنظر إلى كل هذا ، أردنا إنشاء الامتداد الأكثر أمانًا الذي يبسط تطوير التطبيقات اللامركزية من خلال توفير واجهة برمجة تطبيقات بسيطة للعمل مع المعاملات والتوقيعات.
سنخبرك عن هذه التجربة أدناه.

ستوفر المقالة إرشادات خطوة بخطوة حول كيفية كتابة امتداد المتصفح ، مع أمثلة التعليمات البرمجية ولقطات الشاشة. يمكنك أن تجد كل الكود في مستودعات. يتوافق كل التزام منطقيًا مع قسم من هذه المقالة.

تاريخ موجز لملحقات المستعرض

امتدادات المتصفح موجودة منذ فترة طويلة. ظهرت في Internet Explorer عام 1999 ، وفي Firefox عام 2004. ومع ذلك ، لفترة طويلة جدًا لم يكن هناك معيار واحد للتمديدات.

يمكننا القول أنه ظهر مع الإضافات في الإصدار الرابع من Google Chrome. بالطبع ، لم يكن هناك أي مواصفات في ذلك الوقت ، ولكن كانت واجهة برمجة تطبيقات Chrome هي أساسها: بعد أن فازت بجزء كبير من سوق المتصفحات ولديها متجر تطبيقات مدمج ، وضع Chrome بالفعل المعيار لإضافات المتصفح.

كان لدى Mozilla معيارها الخاص ، ولكن نظرًا لشعبية امتدادات Chrome ، قررت الشركة إنشاء واجهة برمجة تطبيقات متوافقة. في عام 2015 ، بمبادرة من Mozilla ، تم إنشاء مجموعة خاصة داخل World Wide Web Consortium (W3C) للعمل على مواصفات امتدادات عبر المستعرضات.

تم أخذ واجهة برمجة تطبيقات إضافة Chrome الحالية كأساس. تم تنفيذ العمل بدعم من 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. استثناء_المطابقات - يستبعد من الميدان match عناوين URL التي تطابق هذا الحقل.
  3. page_action - هو في الواقع كائن مسؤول عن الأيقونة التي تظهر بجوار شريط العنوان في المتصفح ، والتفاعل معها. يسمح لك أيضًا بإظهار نافذة منبثقة ، يتم تعيينها باستخدام HTML و CSS و JS.
    1. default_popup - مسار ملف HTML بواجهة منبثقة ، قد يحتوي على CSS و JS.
  4. أذونات - مصفوفة لإدارة حقوق التمديد. هناك ثلاثة أنواع من الحقوق موصوفة بالتفصيل هنا
  5. مصادر_يمكن الوصول إليها على الويب - موارد الامتداد التي يمكن لصفحة الويب طلبها ، على سبيل المثال ، الصور ، JS ، CSS ، ملفات HTML.
  6. خارجي_ متصل - هنا يمكنك تحديد معرفات الامتدادات الأخرى ومجالات صفحات الويب التي يمكنك الاتصال من خلالها. يمكن أن يكون المجال من المستوى الثاني وما فوق. لا يعمل في Firefox.

سياق التنفيذ

يحتوي الامتداد على ثلاثة سياقات لتنفيذ التعليمات البرمجية ، أي أن التطبيق يتكون من ثلاثة أجزاء بمستويات مختلفة من الوصول إلى واجهة برمجة تطبيقات المتصفح.

سياق التمديد

يتوفر معظم واجهة برمجة التطبيقات هنا. في هذا السياق ، "مباشر":

  1. صفحة الخلفية - جزء "الواجهة الخلفية" من الامتداد. يتم تحديد الملف في البيان بواسطة مفتاح "الخلفية".
  2. صفحة منبثقة - الصفحة المنبثقة التي تظهر عند النقر فوق رمز الامتداد. في البيان browser_action -> default_popup.
  3. صفحة مخصص - صفحة الامتداد "الحية" في علامة تبويب منفصلة للعرض chrome-extension://<id_расширения>/customPage.html.

يوجد هذا السياق بشكل مستقل عن نوافذ المتصفح وعلامات التبويب. صفحة الخلفية موجود في مثيل واحد ويعمل دائمًا (الاستثناء هو صفحة الحدث ، عندما يتم تشغيل البرنامج النصي في الخلفية بواسطة حدث و "يموت" بعد تنفيذه). صفحة منبثقة موجود عندما تكون النافذة المنبثقة مفتوحة ، و صفحة مخصص - عندما تكون علامة التبويب مفتوحة. لا يمكن الوصول إلى علامات التبويب الأخرى ومحتوياتها من هذا السياق.

سياق البرنامج النصي للمحتوى

يتم تشغيل ملف البرنامج النصي للمحتوى مع كل علامة تبويب في المتصفح. لديه حق الوصول إلى جزء من واجهة برمجة التطبيقات الخاصة بالملحق وإلى شجرة 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 لا يمكننا ذلك ، لأن واجهة برمجة التطبيقات مطلوبة في جميع المجالات ، ويمكن تحديد مجالات محددة فقط في البيان. نتيجة لذلك ، سيبدو المخطط كما يلي:

كتابة ملحق متصفح آمن

سيكون هناك نص آخر - inpage، والتي سنحقنها في الصفحة. سيتم تشغيله في سياقه وتوفير واجهة برمجة تطبيقات للعمل مع الامتداد.

بداية

كل كود امتداد المتصفح متاح في 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 و contentcript.js فارغة. نضيف popup.html - ويمكن تحميل تطبيقنا بالفعل في Google Chrome والتأكد من أنه يعمل.

للتحقق من ذلك ، يمكنك أن تأخذ الرمز من هنا. بالإضافة إلى ما فعلناه ، تم تكوين الرابط لإنشاء المشروع باستخدام حزمة الويب. لإضافة التطبيق إلى المتصفح ، في chrome: // extensions ، تحتاج إلى تحديد تحميل unpacked ومجلد بالامتداد المناسب - في حالتنا ، dist.

كتابة ملحق متصفح آمن

الآن تم تثبيت ملحقنا والعمل. يمكنك تشغيل أدوات المطور لسياقات مختلفة على النحو التالي:

منبثقة ->

كتابة ملحق متصفح آمن

يتم الوصول إلى وحدة التحكم في نص المحتوى من خلال وحدة التحكم في الصفحة نفسها ، والتي يتم تشغيلها عليها.كتابة ملحق متصفح آمن

الرسائل

لذلك ، نحتاج إلى إعداد قناتين للاتصال: inpage <-> background و popup <-> background. يمكنك ، بالطبع ، فقط إرسال رسائل إلى المنفذ وابتكار بروتوكول خاص بك ، لكنني أفضل الأسلوب الذي تجسست عليه في مشروع 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 العالمي ، نستخدم extentionApi ، الذي يشير إلى 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()
    }
}

الآن نقوم بإنشاء اتصال في واجهة المستخدم:

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

نظرًا لأننا لا نحتاج إلى واجهة برمجة التطبيقات في النص البرمجي للمحتوى ، ولكن مباشرةً في الصفحة ، فإننا نقوم بأمرين:

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

نقوم الآن بإنشاء كائن 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;
}

نحن جاهزون استدعاء الإجراء البعيد (RPC) مع واجهة برمجة تطبيقات منفصلة للصفحة وواجهة المستخدم. عند توصيل صفحة جديدة بالخلفية ، يمكننا رؤية هذا:

كتابة ملحق متصفح آمن

API وأصل فارغين. على جانب الصفحة ، يمكننا استدعاء وظيفة hello على النحو التالي:

كتابة ملحق متصفح آمن

يعد العمل مع وظائف رد الاتصال في JS الحديثة سلوكًا سيئًا ، لذلك دعونا نكتب مساعدًا صغيرًا لإنشاء dnode يسمح لك بتمريره إلى كائن API في utils.

ستبدو كائنات واجهة برمجة التطبيقات الآن على النحو التالي:

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 والبث مرن للغاية: يمكننا استخدام مضاعفة الإرسال البخاري وإنشاء العديد من واجهات برمجة التطبيقات المختلفة لمهام مختلفة. من حيث المبدأ ، يمكن استخدام dnode في أي مكان ، والشيء الرئيسي هو التفاف النقل في شكل تدفق nodejs.

البديل هو تنسيق JSON ، الذي يطبق بروتوكول JSON RPC 2. ومع ذلك ، فهو يعمل مع وسائل نقل محددة (TCP و HTTP (S)) ، وهو أمر غير قابل للتطبيق في حالتنا.

الحالة الداخلية والتخزين المحلي

سنحتاج إلى تخزين الحالة الداخلية للتطبيق - على الأقل مفاتيح التوقيع. يمكننا بسهولة إضافة حالة إلى التطبيق وطرق لتغييرها في Popup 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)
        }
    }
}

دعنا نضيف بعض المفاتيح من وحدة تحكم واجهة المستخدم ونرى ما يحدث مع الحالة:

كتابة ملحق متصفح آمن

يجب أن تكون الحالة ثابتة حتى لا تضيع المفاتيح عند إعادة التشغيل.

سنقوم بالتخزين في 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 "Under the hood" جميع حقول المتجر بالوكيل وتقوم باعتراض جميع المكالمات الموجهة إليهم. يمكنك الاشتراك في هذه الرسائل.

في ما يلي ، غالبًا ما أستخدم مصطلح "عند التغيير" ، على الرغم من أن هذا ليس صحيحًا تمامًا. Mobx يتتبع الوصول إلى الحقول. يتم استخدام حاصل ومحددات كائنات الوكيل التي تنشئها المكتبة.

يخدم مصممو الأكشن غرضين:

  1. في الوضع المتشدد مع علامة فرض الإجراءات ، يمنع 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')
        }
    }
}

الآن نقوم بتخزين المفاتيح وكلمة المرور المشفرة فقط. كل شيء آخر محسوب. نقوم بالتحويل إلى حالة القفل عن طريق إزالة كلمة المرور من الحالة. واجهة برمجة التطبيقات العامة لديها طريقة لتهيئة المتجر.

مكتوب للتشفير المرافق التي تستخدم 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)
}

يحتوي المتصفح على واجهة برمجة تطبيقات خاملة يمكنك من خلالها الاشتراك في حدث - تغييرات الحالة. الدولة ، على التوالي ، يمكن أن تكون 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)
        }
    }
}

الكود حتى هذه الخطوة هو هنا.

المعاملات

لذلك ، نأتي إلى أهم شيء: إنشاء المعاملات وتوقيعها في blockchain. سوف نستخدم WAVES blockchain والمكتبة موجات المعاملات.

أولاً ، دعنا نضيف إلى الحالة مجموعة من الرسائل التي يجب توقيعها ، ثم - طرق إضافة رسالة جديدة ، وتأكيد التوقيع ورفض:

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 ، newMessage - في واجهة برمجة التطبيقات للصفحة:

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

تحتاج الواجهة إلى الوصول إلى حالة التطبيق. على جانب واجهة المستخدم سنفعل 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 على المكوِّن ، وسيُستدعى العرض تلقائيًا عند تغيير أي عناصر ملحوظة مشار إليها بواسطة المكون. لا حاجة لأي 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>
    }
}

يمكن رؤية باقي المكونات في الكود في مجلد واجهة المستخدم.

الآن في فئة التطبيق ، تحتاج إلى عمل محدد حالة لواجهة المستخدم وإخطار واجهة المستخدم عندما تتغير. للقيام بذلك ، أضف طريقة 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 لتغيير الحالة التي تستدعي الوظيفة على جانب واجهة المستخدم.

اللمسة الأخيرة هي إضافة عرض الرسائل الجديدة على أيقونة التمديد:

function setupApp() {
...

    // Reaction на выставление текста беджа.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

إذن ، التطبيق جاهز. يمكن لصفحات الويب طلب توقيع المعاملات:

كتابة ملحق متصفح آمن

كتابة ملحق متصفح آمن

الرمز متاح في هذا صلة.

اختتام

إذا كنت قد قرأت المقالة حتى النهاية ، ولكن لا تزال لديك أسئلة ، فيمكنك طرحها مستودعات مع التمديد. في نفس المكان سوف تجد التزامات تحت كل خطوة محددة.

وإذا كنت مهتمًا برؤية الكود الخاص بالامتداد الحقيقي ، فيمكنك العثور عليه هنا.

الرمز والمستودع والوصف الوظيفي من سيماريل

المصدر: www.habr.com

إضافة تعليق