Menulis ekstensi browser yang aman


Berbeda dengan arsitektur "client-server" yang umum, aplikasi desentralisasi ditandai oleh:


  • Tidak perlu menyimpan database dengan login dan kata sandi pengguna. Akses informasi disimpan secara eksklusif oleh pengguna sendiri, dan konfirmasi keasliannya terjadi pada tingkat protokol.
  • Tidak perlu menggunakan server. Logika aplikasi dapat dieksekusi pada jaringan blockchain, di mana dimungkinkan untuk menyimpan jumlah data yang diperlukan.

Ada 2 repositori yang relatif aman untuk kunci pengguna - dompet perangkat keras dan ekstensi browser. Sebagian besar dompet perangkat keras seaman mungkin, tetapi sulit digunakan dan jauh dari gratis, tetapi ekstensi browser adalah kombinasi sempurna antara keamanan dan kemudahan penggunaan, dan juga bisa sepenuhnya gratis bagi pengguna akhir.


Mengingat semua ini, kami ingin membuat ekstensi yang paling aman, yang menyederhanakan pengembangan aplikasi terdesentralisasi, menyediakan API sederhana untuk bekerja dengan transaksi dan tanda tangan.
Kami akan memberi tahu Anda tentang pengalaman di bawah ini.


Artikel ini akan memberikan petunjuk langkah demi langkah tentang cara menulis ekstensi browser, dengan contoh kode dan tangkapan layar. Anda dapat menemukan semua kode di repositori . Setiap komit secara logis sesuai dengan bagian dari artikel ini.


Sejarah Singkat Ekstensi Peramban


Ekstensi peramban telah ada selama beberapa waktu. Di Internet Explorer, mereka muncul pada tahun 1999, di Firefox - pada tahun 2004. Namun, untuk waktu yang sangat lama tidak ada standar tunggal untuk ekstensi.


Kita dapat mengatakan bahwa itu muncul bersama dengan ekstensi di Google Chrome versi keempat. Tentu saja, tidak ada spesifikasi saat itu, tetapi Chrome API yang menjadi basisnya: setelah menaklukkan sebagian besar pasar browser dan memiliki toko aplikasi bawaan, Chrome sebenarnya menetapkan standar untuk ekstensi browser.


Mozilla memiliki standar sendiri, tetapi, melihat popularitas ekstensi untuk Chrome, perusahaan memutuskan untuk membuat API yang kompatibel. Pada 2015, atas prakarsa Mozilla, sebuah grup khusus diciptakan di dalam World Wide Web Consortium (W3C) untuk mengerjakan spesifikasi untuk ekstensi lintas browser.


Berdasarkan ekstensi API yang sudah ada untuk Chrome. Pekerjaan itu didukung oleh Microsoft (Google menolak untuk berpartisipasi dalam pengembangan standar), dan sebagai hasilnya, muncul rancangan spesifikasi .


Secara formal, spesifikasinya didukung oleh Edge, Firefox dan Opera (perhatikan bahwa Chrome tidak ada dalam daftar ini). Tetapi pada kenyataannya, standar ini sebagian besar kompatibel dengan Chrome, karena sebenarnya ditulis berdasarkan ekstensi-nya. Baca lebih lanjut tentang API WebExtensions di sini .


Struktur ekstensi


Satu-satunya file yang diperlukan untuk ekstensi adalah manifes (manifest.json). Dia adalah "titik masuk" ke ekstensi.


Terwujud


Berdasarkan spesifikasi, file manifes adalah file JSON yang valid. Penjelasan lengkap tentang kunci manifes dengan informasi tentang kunci mana yang didukung di mana browser dapat ditemukan di sini .


Kunci yang tidak ada dalam spesifikasi mungkin "diabaikan" (baik Chrome dan Firefox melaporkan kesalahan, tetapi ekstensi terus berfungsi).


Dan saya ingin menarik beberapa poin.


  1. background - objek yang mencakup bidang-bidang berikut:
    1. skrip - larik skrip yang akan dieksekusi dalam konteks latar belakang (kita akan membicarakannya nanti);
    2. halaman - alih-alih skrip yang akan dieksekusi pada halaman kosong, Anda dapat menentukan html dengan konten. Dalam hal ini, bidang skrip akan diabaikan, dan skrip perlu dimasukkan ke halaman dengan konten;
    3. persistent - bendera biner, jika tidak ditentukan, browser akan "membunuh" proses latar belakang ketika menganggap bahwa itu tidak melakukan apa-apa, dan restart jika perlu. Kalau tidak, halaman akan diturunkan hanya ketika browser ditutup. Tidak didukung di Firefox.
  2. content_scripts - array objek yang memungkinkan Anda memuat skrip yang berbeda ke halaman web yang berbeda. Setiap objek berisi bidang-bidang penting berikut:
    1. cocok - pola url yang dengannya ditentukan apakah skrip konten tertentu akan disertakan atau tidak.
    2. js - daftar skrip yang akan dimuat ke dalam pertandingan ini;
    3. exclude_matches - mengecualikan URL match dari bidang kecocokan yang cocok dengan bidang ini.
  3. page_action - pada kenyataannya, itu adalah objek yang bertanggung jawab atas ikon yang muncul di sebelah bilah alamat di browser, dan interaksi dengannya. Ini juga memungkinkan Anda untuk menampilkan jendela sembulan, yang ditetapkan menggunakan HTML, CSS, dan JS-nya.
    1. default_popup - path ke file HTML dengan antarmuka popup, mungkin berisi CSS dan JS.
  4. izin - larik untuk mengelola hak ekstensi. Ada 3 jenis hak yang dijelaskan secara rinci di sini.
  5. web_accessible_resources - sumber daya ekstensi yang dapat diminta halaman web, misalnya, gambar, JS, CSS, file HTML.
  6. externally_connectable - di sini Anda dapat secara eksplisit menentukan ID ekstensi lain dan domain halaman web tempat Anda dapat terhubung. Sebuah domain bisa menjadi level kedua atau lebih tinggi. Tidak berfungsi di Firefox.

Konteks eksekusi


Ekstensi memiliki tiga konteks eksekusi kode, yaitu, aplikasi terdiri dari tiga bagian dengan berbagai tingkat akses ke API browser.


Konteks ekstensi


Sebagian besar API tersedia di sini. Dalam konteks ini, "hidup":


  1. Halaman latar belakang - bagian "backend" dari ekstensi. File ditunjukkan dalam manifes oleh tombol "latar belakang".
  2. Halaman popup - halaman popup yang muncul ketika Anda mengklik ikon ekstensi. Dalam manifes, browser_action -> default_popup .
  3. Halaman khusus - halaman ekstensi, "tinggal" di tab terpisah dari formulir chrome-extension://<id_>/customPage.html .

Konteks ini ada secara independen dari jendela dan tab browser. Halaman latar belakang ada dalam satu salinan dan selalu berfungsi (pengecualiannya adalah halaman acara, ketika skrip latar belakang diluncurkan pada suatu acara dan mati setelah dijalankan). Halaman popup ada ketika jendela popup terbuka, dan halaman Custom - sementara tab dengan itu terbuka. Tidak ada akses ke tab lain dan isinya dari konteks ini.


Konteks skrip konten


File skrip konten diluncurkan bersama dengan setiap tab browser. Dia memiliki akses ke bagian dari API ekstensi dan ke pohon DOM halaman web. Skrip konten bertanggung jawab untuk berinteraksi dengan halaman. Ekstensi yang memanipulasi pohon DOM melakukan hal ini dalam skrip konten - misalnya, pemblokir iklan atau penerjemah. Juga, skrip konten dapat berkomunikasi dengan halaman melalui postMessage standar.


Konteks halaman web


Ini sebenarnya halaman web itu sendiri. Itu tidak ada hubungannya dengan ekstensi dan tidak memiliki akses di sana, kecuali domain halaman ini tidak secara eksplisit ditentukan dalam manifes (lebih lanjut tentang ini di bawah).


Olahpesan


Bagian aplikasi yang berbeda harus saling bertukar pesan. Untuk melakukan ini, ada runtime.sendMessage API untuk mengirim pesan background dan tabs.sendMessage untuk mengirim pesan ke halaman (ke skrip konten, popup atau halaman web jika ada tabs.sendMessage externally_connectable ). Berikut ini adalah contoh ketika mengakses 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)) } ) 

Untuk komunikasi penuh, Anda dapat membuat koneksi melalui runtime.connect . Sebagai tanggapan, kami mendapatkan runtime.Port , di mana, saat terbuka, Anda dapat mengirim sejumlah pesan. Di sisi klien, misalnya contentscript , terlihat seperti ini:


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

Server atau latar belakang:


 //    '' .  , 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) { ... }); 

Ada juga acara onDisconnect dan metode disconnect .


Garis besar aplikasi


Mari kita buat ekstensi browser yang menyimpan kunci pribadi, menyediakan akses ke informasi publik (alamat, kunci publik berkomunikasi dengan halaman dan memungkinkan aplikasi pihak ketiga untuk meminta tanda tangan transaksi.


Pengembangan aplikasi


Aplikasi kita harus berinteraksi dengan pengguna dan menyediakan halaman API untuk metode panggilan (misalnya, untuk menandatangani transaksi). Ini tidak akan bekerja dengan contentscript saja, karena hanya memiliki akses ke DOM, tetapi tidak ke halaman JS. Kami tidak dapat terhubung melalui runtime.connect , karena API diperlukan di semua domain, dan hanya yang spesifik yang dapat ditentukan dalam manifes. Hasilnya, skema akan terlihat seperti ini:



Akan ada skrip lain - inpage , yang akan kami inpage ke halaman. Ini akan berjalan dalam konteksnya dan menyediakan API untuk bekerja dengan ekstensi.


Mulai


Semua kode ekstensi peramban tersedia di GitHub . Dalam proses deskripsi, akan ada tautan ke komit.


Mari kita mulai dengan manifes:


 { //   , .        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"] } 

Buat background.js, popup.js, inpage.js, dan contentcript.js kosong. Tambahkan popup.html - dan aplikasi kami sudah dapat diunduh ke Google Chrome dan pastikan itu berfungsi.


Untuk memverifikasi ini, Anda dapat mengambil kode dari sini . Selain apa yang kami lakukan, tautan tersebut dikonfigurasi untuk membangun proyek menggunakan webpack. Untuk menambahkan aplikasi ke browser, di chrome: // ekstensi Anda harus memilih memuat dibuka dan folder dengan ekstensi yang sesuai - dalam kasus kami, dist.



Sekarang ekstensi kami sudah terpasang dan berfungsi. Anda dapat menjalankan alat pengembang untuk konteks yang berbeda sebagai berikut:


popup ->



Akses ke konsol skrip konten dilakukan melalui konsol halaman itu sendiri di mana ia diluncurkan.


Olahpesan


Jadi, kita perlu membuat dua saluran komunikasi: inpage <-> background dan popup <-> background. Anda dapat, tentu saja, hanya mengirim pesan ke port dan menciptakan protokol Anda, tetapi saya lebih suka pendekatan yang saya amati pada proyek metamask open-source.


Ini adalah ekstensi browser untuk bekerja dengan jaringan Ethereum. Di dalamnya, berbagai bagian aplikasi berkomunikasi melalui RPC menggunakan pustaka dnode. Ini memungkinkan Anda untuk dengan cepat dan mudah mengatur pertukaran jika Anda memberikan aliran nodejs sebagai transportasi (artinya objek yang mengimplementasikan antarmuka yang sama):


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

Sekarang kita akan membuat kelas aplikasi. Ini akan membuat objek API untuk popup dan halaman web, dan juga membuat dnode untuk mereka:


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

Selanjutnya, alih-alih objek Chrome global, kami menggunakan extentionApi, yang merujuk ke Chrome di peramban dari Google dan ke peramban lainnya. Ini dilakukan untuk kompatibilitas lintas-browser, tetapi cukup chrome.runtime.connect dapat digunakan dalam kerangka kerja artikel ini.


Buat instance aplikasi dalam skrip latar belakang:


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

Karena dnode bekerja dengan stream, dan kami mendapatkan port, kelas adaptor diperlukan. Itu dibuat menggunakan pustaka stream yang dapat dibaca, yang mengimplementasikan stream nodejs di browser:


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

Sekarang buat koneksi di 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; } } 

Kemudian kami membuat koneksi di skrip konten:


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

Karena kita tidak memerlukan API dalam skrip konten, tetapi langsung di halaman, kita melakukan dua hal:


  1. Kami membuat dua aliran. Salah satunya adalah menuju halaman, di atas postMessage. Untuk ini, kami menggunakan paket ini dari pencipta metamask. Aliran kedua adalah ke latar belakang di atas port yang diterima dari runtime.connect . Pip mereka. Sekarang halaman akan memiliki aliran ke latar belakang.
  2. Suntikkan skrip ke DOM. Kami memompa skrip (akses ke sana diizinkan dalam manifes) dan membuat tag script dengan isinya di dalam:

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

Sekarang buat objek api di inpage dan mulai secara global:


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

Kami siap untuk Panggilan Prosedur Jarak Jauh (RPC) dengan API terpisah untuk halaman dan UI . Saat menghubungkan halaman baru ke latar belakang, kita dapat melihat ini:



API dan asal kosong. Di sisi halaman, kita bisa memanggil fungsi halo seperti ini:



Bekerja dengan fungsi callback di JS modern adalah ide yang buruk, oleh karena itu kami akan menulis pembantu kecil untuk membuat dnode yang memungkinkan Anda meneruskan API ke utilitas dalam suatu objek.


Objek API sekarang akan terlihat seperti ini:


 export class SignerApp { popupApi() { return { hello: async () => "world" } } ... } 

Mendapatkan objek dari jarak jauh sebagai berikut:


 import {cbToPromise, transformMethods} from "../../src/utils/setupDnode"; const pageApi = await new Promise(resolve => { dnode.once('remote', remoteApi => { //      callback  promise resolve(transformMethods(cbToPromise, remoteApi)) }) }); 

Panggilan fungsi mengembalikan janji:



Versi dengan fungsi asinkron tersedia di sini .


Secara umum, pendekatan dengan RPC dan stream tampaknya cukup fleksibel: kita dapat menggunakan multiplexing uap dan membuat beberapa API berbeda untuk tugas yang berbeda. Pada prinsipnya, dnode dapat digunakan di mana saja, hal utama adalah untuk membungkus transportasi dalam bentuk aliran nodejs.


Alternatifnya adalah format JSON, yang mengimplementasikan protokol JSON RPC 2. Namun, ia bekerja dengan transport spesifik (TCP dan HTTP (S)), yang tidak berlaku dalam kasus kami.


Status internal dan penyimpanan lokal


Kita perlu menyimpan keadaan internal aplikasi - setidaknya, kunci untuk penandatanganan. Kami dapat dengan mudah menambahkan status ke aplikasi dan metode untuk mengubahnya di 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) } } ... } 

Di latar belakang, kami akan membungkus semuanya dalam suatu fungsi dan menulis objek aplikasi ke jendela sehingga Anda dapat bekerja dengannya dari konsol:


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

Tambahkan beberapa tombol dari konsol UI dan lihat apa yang terjadi dengan keadaan:



Status harus persisten sehingga ketika Anda me-restart kunci tidak hilang.


Kami akan menyimpannya di Penyimpanan lokal, menimpa dengan setiap perubahan. Selanjutnya, akses ke sana juga akan diperlukan untuk UI, dan saya juga ingin berlangganan perubahan. Berdasarkan hal ini, akan lebih mudah untuk membuat penyimpanan yang dapat diobservasi dan berlangganan perubahannya.


Kami akan menggunakan pustaka mobx ( https://github.com/mobxjs/mobx ). Pilihan ada pada dirinya, karena saya tidak harus bekerja dengannya, tetapi saya benar-benar ingin mempelajarinya.


Tambahkan inisialisasi kondisi awal dan buat toko dapat diamati:


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

"Di bawah tenda" mobx mengganti semua bidang penyimpanan dengan proxy dan memotong semua panggilan ke sana. Anda dapat berlangganan banding ini.


Lebih jauh, saya akan sering menggunakan istilah "saat perubahan", meskipun ini tidak sepenuhnya benar. Mobx melacak akses ke bidang. Getter dan setter dari objek proxy yang dibuat pustaka digunakan.


Dekorator aksi memiliki dua tujuan:


  1. Dalam mode ketat dengan flag menegakkanAksi, mobx melarang mengubah status secara langsung. Ini dianggap praktik yang baik untuk bekerja dalam mode ketat.
  2. Bahkan jika fungsi mengubah keadaan beberapa kali - misalnya, kami mengubah beberapa bidang menjadi beberapa baris kode - pengamat diberitahu hanya ketika itu selesai. Ini sangat penting untuk frontend, di mana pembaruan keadaan yang tidak perlu menyebabkan rendering elemen yang tidak perlu. Dalam kasus kami, baik yang pertama maupun yang kedua tidak terlalu relevan, namun, kami akan mengikuti praktik terbaik. Dekorator memutuskan untuk menggantung pada semua fungsi yang mengubah keadaan bidang yang diamati.

Di latar belakang, tambahkan inisialisasi dan simpan status di 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) } } } 

Fungsi reaksi menarik di sini. Dia memiliki dua argumen:


  1. Pemilih data.
  2. Penangan yang akan dipanggil dengan data ini setiap kali ada perubahan.

Tidak seperti redux, di mana kita secara eksplisit mendapatkan negara sebagai argumen, mobx mengingat yang bisa kita amati di dalam pemilih, dan hanya ketika mengubahnya mereka memanggil pawang.


Sangat penting untuk memahami dengan tepat bagaimana mobx memutuskan mana yang dapat kita ikuti berlangganan. Jika saya menulis pemilih dalam kode seperti ini () => app.store , maka reaksi tidak akan pernah dipanggil, karena repositori itu sendiri tidak dapat diamati, hanya bidangnya yang seperti itu.


Jika saya menulis seperti ini () => app.store.keys , maka tidak ada yang akan terjadi lagi, karena ketika menambahkan / menghapus elemen array, tautan ke sana tidak akan berubah.


Untuk pertama kalinya, Mobx melakukan fungsi pemilih dan memantau hanya mereka yang dapat kita akses. Ini dilakukan melalui getter proxy. toJS . , . – , .


popup . localStorage:



background- .


.



: , , . localStorage .


locked, . locked .


Mobx , . — computed properties. view :


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

. . locked . API .


rypto-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 API, — . , , idle , active locked . idle , locked , . localStorage:


 import {reaction, toJS} from 'mobx'; import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {SignerApp} from "./SignerApp"; import {loadState, saveState} from "./utils/localStorage"; const DEV_MODE = process.env.NODE_ENV !== 'production'; const IDLE_INTERVAL = 30; setupApp(); function setupApp() { const initState = loadState(); const app = new SignerApp(initState); if (DEV_MODE) { global.app = app; } //     ,    , 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) } } } 

.


Transaksi


, : . WAVES waves-transactions .


, , — , :


 import {action, observable, reaction} from 'mobx'; import uuid from 'uuid/v4'; import {signTx} from '@waves/waves-transactions' import {setupDnode} from "./utils/setupDnode"; import {decrypt, encrypt} from "./utils/cryptoUtils"; export class SignerApp { ... @action newMessage(data, origin) { //       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 messages. , , .


, . reaction, "" .


approve reject : , , .


Approve reject API UI, newMessage — API :


 export class SignerApp { ... popupApi() { return { addKey: async (key) => this.addKey(key), removeKey: async (index) => this.removeKey(index), lock: async () => this.lock(), unlock: async (password) => this.unlock(password), initVault: async (password) => this.initVault(password), approve: async (id, keyIndex) => this.approve(id, keyIndex), reject: async (id) => this.reject(id) } } pageApi(origin) { return { signTransaction: async (txParams) => this.newMessage(txParams, origin) } } ... } 

:



, UI .


UI


. UI observable API , . observable API, background:


 import {observable} from 'mobx' import {extensionApi} from "./utils/extensionApi"; import {PortStream} from "./utils/PortStream"; import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode"; import {initApp} from "./ui/index"; const DEV_MODE = process.env.NODE_ENV !== 'production'; setupUi().catch(console.error); async function setupUi() { //   ,     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-. Background- props. , , store , :


 import {render} from 'react-dom' import App from './App' import React from "react"; //    background     props export async function initApp(background){ render( <App background={background}/>, document.getElementById('app-content') ); } 

mobx . observer mobx-react , observable, . mapStateToProps connect, redux. " ":


 import React, {Component, Fragment} from 'react' import {observer} from "mobx-react"; import Init from './components/Initialize' import Keys from './components/Keys' import Sign from './components/Sign' import Unlock from './components/Unlock' @observer //          render,    observable     export default class App extends Component { //              , //   observable   background    ,    render() { const {keys, messages, initialized, locked} = this.props.background.state; const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background; return <Fragment> {!initialized ? <Init onInit={initVault}/> : locked ? <Unlock onUnlock={unlock}/> : messages.length > 0 ? <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/> : <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/> } <div> {!locked && <button onClick={() => lock()}>Lock App</button>} {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>} </div> </Fragment> } } 

UI .


UI UI. getState reaction , remote.updateState :


 import {action, observable, reaction} from 'mobx'; import uuid from 'uuid/v4'; import {signTx} from '@waves/waves-transactions' import {setupDnode} from "./utils/setupDnode"; import {decrypt, encrypt} from "./utils/cryptoUtils"; export class SignerApp { ... // public getState() { return { keys: this.store.keys, messages: this.store.newMessages, initialized: this.store.initialized, locked: this.store.locked } } ... // connectPopup(connectionStream) { const api = this.popupApi(); const dnode = setupDnode(connectionStream, api); dnode.once('remote', (remote) => { //  reaction   ,          ui  const updateStateReaction = reaction( () => this.getState(), (state) => remote.updateState(state), //     . fireImmediatly   reaction    . //  ,    . Delay   debounce {fireImmediately: true, delay: 500} ); //      dnode.once('end', () => updateStateReaction.dispose()) }) } ... } 

remote reaction , UI.


— :


 function setupApp() { ... // Reaction    . reaction( () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '', text => extensionApi.browserAction.setBadgeText({text}), {fireImmediately: true} ); ... } 

, . - :




.


Kesimpulan


, , . .


, .


, siemarell

Source: https://habr.com/ru/post/id451796/


All Articles