Γράψτε μια ασφαλή επέκταση προγράμματος περιήγησης

Γράψτε μια ασφαλή επέκταση προγράμματος περιήγησης

Σε αντίθεση με την κοινή αρχιτεκτονική «πελάτη-διακομιστή», οι αποκεντρωμένες εφαρμογές χαρακτηρίζονται από:

  • Δεν χρειάζεται να αποθηκεύσετε μια βάση δεδομένων με στοιχεία σύνδεσης χρηστών και κωδικούς πρόσβασης. Οι πληροφορίες πρόσβασης αποθηκεύονται αποκλειστικά από τους ίδιους τους χρήστες και η επιβεβαίωση της αυθεντικότητάς τους πραγματοποιείται σε επίπεδο πρωτοκόλλου.
  • Δεν χρειάζεται να χρησιμοποιήσετε διακομιστή. Η λογική της εφαρμογής μπορεί να εκτελεστεί σε ένα δίκτυο blockchain, όπου είναι δυνατή η αποθήκευση της απαιτούμενης ποσότητας δεδομένων.

Υπάρχουν 2 σχετικά ασφαλείς αποθηκευτικοί χώροι για κλειδιά χρήστη - πορτοφόλια υλικού και επεκτάσεις προγράμματος περιήγησης. Τα πορτοφόλια υλικού είναι ως επί το πλείστον εξαιρετικά ασφαλή, αλλά δύσχρηστα και κάθε άλλο παρά δωρεάν, αλλά οι επεκτάσεις του προγράμματος περιήγησης είναι ο τέλειος συνδυασμός ασφάλειας και ευκολίας χρήσης και μπορούν επίσης να είναι εντελώς δωρεάν για τους τελικούς χρήστες.

Λαμβάνοντας όλα αυτά υπόψη, θέλαμε να κάνουμε την πιο ασφαλή επέκταση που απλοποιεί την ανάπτυξη αποκεντρωμένων εφαρμογών παρέχοντας ένα απλό API για εργασία με συναλλαγές και υπογραφές.
Θα σας πούμε για αυτήν την εμπειρία παρακάτω.

Το άρθρο θα περιέχει οδηγίες βήμα προς βήμα για το πώς να γράψετε μια επέκταση προγράμματος περιήγησης, με παραδείγματα κώδικα και στιγμιότυπα οθόνης. Μπορείτε να βρείτε όλο τον κωδικό στο αποθετήρια. Κάθε δέσμευση αντιστοιχεί λογικά σε μια ενότητα αυτού του άρθρου.

Σύντομο ιστορικό επεκτάσεων προγράμματος περιήγησης

Οι επεκτάσεις προγράμματος περιήγησης υπάρχουν εδώ και πολύ καιρό. Εμφανίστηκαν στον Internet Explorer το 1999, στον Firefox το 2004. Ωστόσο, για πολύ μεγάλο χρονικό διάστημα δεν υπήρχε ενιαίο πρότυπο για επεκτάσεις.

Μπορούμε να πούμε ότι εμφανίστηκε μαζί με επεκτάσεις στην τέταρτη έκδοση του Google Chrome. Φυσικά, τότε δεν υπήρχαν προδιαγραφές, αλλά ήταν το Chrome API που έγινε η βάση του: έχοντας κατακτήσει το μεγαλύτερο μέρος της αγοράς του προγράμματος περιήγησης και έχοντας ένα ενσωματωμένο κατάστημα εφαρμογών, ο Chrome έθεσε στην πραγματικότητα τα πρότυπα για τις επεκτάσεις του προγράμματος περιήγησης.

Το Mozilla είχε το δικό του πρότυπο, αλλά βλέποντας τη δημοτικότητα των επεκτάσεων του Chrome, η εταιρεία αποφάσισε να δημιουργήσει ένα συμβατό API. Το 2015, με πρωτοβουλία της Mozilla, δημιουργήθηκε μια ειδική ομάδα στο πλαίσιο της Κοινοπραξίας του Παγκόσμιου Ιστού (W3C) για να εργαστεί σε προδιαγραφές επέκτασης μεταξύ προγραμμάτων περιήγησης.

Ως βάση ελήφθησαν οι υπάρχουσες επεκτάσεις API για το Chrome. Η εργασία πραγματοποιήθηκε με την υποστήριξη της Microsoft (η Google αρνήθηκε να συμμετάσχει στην ανάπτυξη του προτύπου) και ως αποτέλεσμα εμφανίστηκε ένα προσχέδιο Προδιαγραφές.

Επίσημα, η προδιαγραφή υποστηρίζεται από τους Edge, Firefox και Opera (σημειώστε ότι το Chrome δεν περιλαμβάνεται σε αυτήν τη λίστα). Αλλά στην πραγματικότητα, το πρότυπο είναι σε μεγάλο βαθμό συμβατό με το Chrome, αφού στην πραγματικότητα είναι γραμμένο με βάση τις επεκτάσεις του. Μπορείτε να διαβάσετε περισσότερα για το WebExtensions API εδώ.

Δομή επέκτασης

Το μόνο αρχείο που απαιτείται για την επέκταση είναι το manifest (manifest.json). Είναι επίσης το «σημείο εισόδου» στην επέκταση.

Μανιφέστο

Σύμφωνα με τις προδιαγραφές, το αρχείο δήλωσης είναι έγκυρο αρχείο JSON. Μια πλήρης περιγραφή των κλειδιών δήλωσης με πληροφορίες σχετικά με τα κλειδιά που υποστηρίζονται σε ποιο πρόγραμμα περιήγησης μπορεί να προβληθεί εδώ.

Τα κλειδιά που δεν περιλαμβάνονται στην προδιαγραφή "μπορεί" να αγνοηθούν (τόσο το Chrome όσο και ο Firefox αναφέρουν σφάλματα, αλλά οι επεκτάσεις συνεχίζουν να λειτουργούν).

Και θα ήθελα να επιστήσω την προσοχή σε ορισμένα σημεία.

  1. φόντο — ένα αντικείμενο που περιλαμβάνει τα ακόλουθα πεδία:
    1. Εφαρμογές — μια σειρά από σενάρια που θα εκτελεστούν στο παρασκήνιο (θα μιλήσουμε για αυτό λίγο αργότερα).
    2. σελίδα - αντί για σενάρια που θα εκτελεστούν σε μια κενή σελίδα, μπορείτε να καθορίσετε html με περιεχόμενο. Σε αυτήν την περίπτωση, το πεδίο σεναρίου θα αγνοηθεί και τα σενάρια θα πρέπει να εισαχθούν στη σελίδα περιεχομένου.
    3. επίμονος — μια δυαδική σημαία, εάν δεν έχει καθοριστεί, το πρόγραμμα περιήγησης θα «σκοτώσει» τη διαδικασία παρασκηνίου όταν κρίνει ότι δεν κάνει τίποτα και θα την επανεκκινήσει εάν είναι απαραίτητο. Διαφορετικά, η σελίδα θα εκφορτωθεί μόνο όταν το πρόγραμμα περιήγησης είναι κλειστό. Δεν υποστηρίζεται στον Firefox.
  2. περιεχόμενο_σενάρια — μια σειρά αντικειμένων που σας επιτρέπει να φορτώνετε διαφορετικά σενάρια σε διαφορετικές ιστοσελίδες. Κάθε αντικείμενο περιέχει τα ακόλουθα σημαντικά πεδία:
    1. σπίρτα - url μοτίβου, το οποίο καθορίζει εάν ένα συγκεκριμένο σενάριο περιεχομένου θα συμπεριληφθεί ή όχι.
    2. js — μια λίστα με σενάρια που θα φορτωθούν σε αυτήν την αντιστοίχιση.
    3. exclude_match - αποκλείει από το γήπεδο match Διευθύνσεις URL που αντιστοιχούν σε αυτό το πεδίο.
  3. page_action - είναι στην πραγματικότητα ένα αντικείμενο που είναι υπεύθυνο για το εικονίδιο που εμφανίζεται δίπλα στη γραμμή διευθύνσεων στο πρόγραμμα περιήγησης και την αλληλεπίδραση με αυτό. Σας επιτρέπει επίσης να εμφανίσετε ένα αναδυόμενο παράθυρο, το οποίο ορίζεται χρησιμοποιώντας το δικό σας HTML, CSS και JS.
    1. default_popup — η διαδρομή προς το αρχείο HTML με την αναδυόμενη διεπαφή, μπορεί να περιέχει CSS και JS.
  4. δικαιώματα — έναν πίνακα για τη διαχείριση δικαιωμάτων επέκτασης. Υπάρχουν 3 είδη δικαιωμάτων, τα οποία περιγράφονται αναλυτικά εδώ
  5. web_accessible_resources — πόροι επέκτασης που μπορεί να ζητήσει μια ιστοσελίδα, για παράδειγμα, εικόνες, αρχεία JS, CSS, HTML.
  6. εξωτερικά_συνδεόμενο — εδώ μπορείτε να καθορίσετε ρητά τα αναγνωριστικά άλλων επεκτάσεων και τομέων ιστοσελίδων από τις οποίες μπορείτε να συνδεθείτε. Ένας τομέας μπορεί να είναι δεύτερου επιπέδου ή υψηλότερου. Δεν λειτουργεί στον Firefox.

Πλαίσιο εκτέλεσης

Η επέκταση έχει τρία περιβάλλοντα εκτέλεσης κώδικα, δηλαδή η εφαρμογή αποτελείται από τρία μέρη με διαφορετικά επίπεδα πρόσβασης στο API του προγράμματος περιήγησης.

Πλαίσιο επέκτασης

Το μεγαλύτερο μέρος του API είναι διαθέσιμο εδώ. Σε αυτό το πλαίσιο «ζουν»:

  1. Σελίδα φόντου — τμήμα "backend" της επέκτασης. Το αρχείο καθορίζεται στο μανιφέστο χρησιμοποιώντας το κλειδί "φόντο".
  2. Αναδυόμενη σελίδα — μια αναδυόμενη σελίδα που εμφανίζεται όταν κάνετε κλικ στο εικονίδιο της επέκτασης. Στο μανιφέστο browser_action -> default_popup.
  3. Προσαρμοσμένη σελίδα — σελίδα επέκτασης, "ζωντανό" σε ξεχωριστή καρτέλα της προβολής chrome-extension://<id_расширения>/customPage.html.

Αυτό το πλαίσιο υπάρχει ανεξάρτητα από τα παράθυρα και τις καρτέλες του προγράμματος περιήγησης. Σελίδα φόντου υπάρχει σε ένα μόνο αντίγραφο και λειτουργεί πάντα (η εξαίρεση είναι η σελίδα συμβάντος, όταν το σενάριο φόντου εκκινείται από ένα συμβάν και "πεθαίνει" μετά την εκτέλεσή του). Αναδυόμενη σελίδα υπάρχει όταν το αναδυόμενο παράθυρο είναι ανοιχτό και Προσαρμοσμένη σελίδα — ενώ η καρτέλα με αυτό είναι ανοιχτή. Δεν υπάρχει πρόσβαση σε άλλες καρτέλες και στο περιεχόμενό τους από αυτό το πλαίσιο.

Περιεχόμενο σεναρίου περιεχομένου

Το αρχείο σεναρίου περιεχομένου εκκινείται μαζί με κάθε καρτέλα του προγράμματος περιήγησης. Έχει πρόσβαση σε μέρος του API της επέκτασης και στο δέντρο DOM της ιστοσελίδας. Είναι τα σενάρια περιεχομένου που είναι υπεύθυνα για την αλληλεπίδραση με τη σελίδα. Οι επεκτάσεις που χειρίζονται το δέντρο DOM το κάνουν αυτό σε σενάρια περιεχομένου - για παράδειγμα, προγράμματα αποκλεισμού διαφημίσεων ή μεταφραστές. Επίσης, το σενάριο περιεχομένου μπορεί να επικοινωνεί με τη σελίδα μέσω τυπικού postMessage.

Περιβάλλον ιστοσελίδας

Αυτή είναι η ίδια η ιστοσελίδα. Δεν έχει καμία σχέση με την επέκταση και δεν έχει πρόσβαση εκεί, εκτός από τις περιπτώσεις που ο τομέας αυτής της σελίδας δεν αναφέρεται ρητά στο μανιφέστο (περισσότερα για αυτό παρακάτω).

Μηνύματα

Διαφορετικά μέρη της εφαρμογής πρέπει να ανταλλάσσουν μηνύματα μεταξύ τους. Υπάρχει ένα API για αυτό runtime.sendMessage για να στείλετε ένα μήνυμα background и tabs.sendMessage για να στείλετε ένα μήνυμα σε μια σελίδα (σενάριο περιεχομένου, αναδυόμενο παράθυρο ή ιστοσελίδα εάν υπάρχει externally_connectable). Ακολουθεί ένα παράδειγμα κατά την πρόσβαση στο API του Chrome.

// Сообщением может быть любой 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 πρέπει να επιλέξετε load unpacked και το φάκελο με την αντίστοιχη επέκταση - στην περίπτωσή μας 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. Δημιουργούμε δύο ροές. Ένα - προς τη σελίδα, πάνω από το 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 για σελίδα και διεπαφή χρήστη. Όταν συνδέουμε μια νέα σελίδα στο φόντο, μπορούμε να δούμε αυτό:

Γράψτε μια ασφαλή επέκταση προγράμματος περιήγησης

Κενό 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 και ροής φαίνεται αρκετά ευέλικτη: μπορούμε να χρησιμοποιήσουμε την πολυπλεξία ατμού και να δημιουργήσουμε πολλά διαφορετικά API για διαφορετικές εργασίες. Κατ 'αρχήν, το dnode μπορεί να χρησιμοποιηθεί οπουδήποτε, το κύριο πράγμα είναι να τυλίξετε τη μεταφορά με τη μορφή ροής nodejs.

Μια εναλλακτική είναι η μορφή JSON, η οποία υλοποιεί το πρωτόκολλο JSON RPC 2. Ωστόσο, λειτουργεί με συγκεκριμένες μεταφορές (TCP και HTTP(S)), κάτι που δεν ισχύει στην περίπτωσή μας.

Εσωτερική κατάσταση και τοπική αποθήκευση

Θα χρειαστεί να αποθηκεύσουμε την εσωτερική κατάσταση της εφαρμογής - τουλάχιστον τα κλειδιά υπογραφής. Μπορούμε πολύ εύκολα να προσθέσουμε μια κατάσταση στην εφαρμογή και μεθόδους για την αλλαγή της στο αναδυόμενο API:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

    removeKey(index){
        this.store.keys.splice(index,1)
    }

    popupApi(){
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index)
        }
    }

    ...

} 

Στο παρασκήνιο, θα τυλίξουμε τα πάντα σε μια συνάρτηση και θα γράψουμε το αντικείμενο της εφαρμογής στο παράθυρο, ώστε να μπορούμε να εργαστούμε μαζί του από την κονσόλα:

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

    ...

}

Το "Under the hood", το mobx έχει αντικαταστήσει όλα τα πεδία καταστήματος με διακομιστή μεσολάβησης και παρεμποδίζει όλες τις κλήσεις προς αυτά. Θα είναι δυνατή η εγγραφή σε αυτά τα μηνύματα.

Παρακάτω θα χρησιμοποιώ συχνά τον όρο "όταν αλλάζω", αν και αυτό δεν είναι απολύτως σωστό. Το Mobx παρακολουθεί την πρόσβαση στα πεδία. Χρησιμοποιούνται λήπτες και ρυθμιστές αντικειμένων διακομιστή μεσολάβησης που δημιουργεί η βιβλιοθήκη.

Οι διακοσμητές δράσης εξυπηρετούν δύο σκοπούς:

  1. Σε αυστηρή λειτουργία με τη σημαία enforceActions, το mobx απαγορεύει την άμεση αλλαγή της κατάστασης. Θεωρείται καλή πρακτική η εργασία υπό αυστηρές συνθήκες.
  2. Ακόμα κι αν μια συνάρτηση αλλάξει την κατάσταση αρκετές φορές - για παράδειγμα, αλλάξουμε πολλά πεδία σε πολλές γραμμές κώδικα - οι παρατηρητές ειδοποιούνται μόνο όταν ολοκληρωθεί. Αυτό είναι ιδιαίτερα σημαντικό για το frontend, όπου οι μη απαραίτητες ενημερώσεις κατάστασης οδηγούν σε περιττή απόδοση στοιχείων. Στην περίπτωσή μας, ούτε το πρώτο ούτε το δεύτερο είναι ιδιαίτερα σχετικό, αλλά θα ακολουθήσουμε τις βέλτιστες πρακτικές. Είναι σύνηθες να προσαρτώνται διακοσμητές σε όλες τις λειτουργίες που αλλάζουν την κατάσταση των παρατηρούμενων πεδίων.

Στο παρασκήνιο θα προσθέσουμε προετοιμασία και αποθήκευση της κατάστασης στο 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 λειτουργεί ως επιλογέας για πρώτη φορά και παρακολουθεί μόνο τα παρατηρήσιμα στοιχεία στα οποία έχουμε πρόσβαση. Αυτό γίνεται μέσω proxy getters. Επομένως, εδώ χρησιμοποιείται η ενσωματωμένη συνάρτηση 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)
        }
    }
}

Ο κωδικός πριν από αυτό το βήμα είναι εδώ.

Συναλλαγές

Έτσι, φτάνουμε στο πιο σημαντικό πράγμα: τη δημιουργία και την υπογραφή συναλλαγών στο blockchain. Θα χρησιμοποιήσουμε το blockchain και τη βιβλιοθήκη 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 πολύ απλό: απλώς αλλάζουμε την κατάσταση του μηνύματος, αφού το υπογράψουμε αν χρειαστεί.

Βάζουμε το Approve and 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 θα κάνουμε 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)
}

Στο τέλος ξεκινάμε την απόδοση της διεπαφής της εφαρμογής. Αυτή είναι μια εφαρμογή react. Το αντικείμενο φόντου απλώς μεταβιβάζεται χρησιμοποιώντας στηρίγματα. Θα ήταν σωστό, φυσικά, να κάνουμε μια ξεχωριστή υπηρεσία για μεθόδους και ένα κατάστημα για το κράτος, αλλά για τους σκοπούς αυτού του άρθρου αυτό αρκεί:

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 στο στοιχείο και το render θα κληθεί αυτόματα όταν αλλάξουν τυχόν παρατηρήσιμα στοιχεία που αναφέρονται από το στοιχείο. Δεν χρειάζεστε κανένα 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.

Τώρα στην κλάση εφαρμογών πρέπει να δημιουργήσετε έναν επιλογέα κατάστασης για τη διεπαφή χρήστη και να ειδοποιήσετε τη διεπαφή χρήστη όταν αλλάξει. Για να γίνει αυτό, ας προσθέσουμε μια μέθοδο 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}
    );

...
}

Λοιπόν, η εφαρμογή είναι έτοιμη. Οι ιστοσελίδες ενδέχεται να ζητούν υπογραφή για συναλλαγές:

Γράψτε μια ασφαλή επέκταση προγράμματος περιήγησης

Γράψτε μια ασφαλή επέκταση προγράμματος περιήγησης

Ο κωδικός είναι διαθέσιμος εδώ σύνδεσμος.

Συμπέρασμα

Εάν έχετε διαβάσει το άρθρο μέχρι το τέλος, αλλά εξακολουθείτε να έχετε ερωτήσεις, μπορείτε να τις ρωτήσετε στο αποθετήρια με επέκταση. Εκεί θα βρείτε επίσης δεσμεύσεις για κάθε καθορισμένο βήμα.

Και αν ενδιαφέρεστε να δείτε τον κωδικό για την πραγματική επέκταση, μπορείτε να τον βρείτε εδώ.

Κωδικός, αποθετήριο και περιγραφή εργασίας από siemarell

Πηγή: www.habr.com

Προσθέστε ένα σχόλιο