
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.
- background - objek yang mencakup bidang-bidang berikut:
- skrip - larik skrip yang akan dieksekusi dalam konteks latar belakang (kita akan membicarakannya nanti);
- 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;
- 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.
- content_scripts - array objek yang memungkinkan Anda memuat skrip yang berbeda ke halaman web yang berbeda. Setiap objek berisi bidang-bidang penting berikut:
- cocok - pola url yang dengannya ditentukan apakah skrip konten tertentu akan disertakan atau tidak.
- js - daftar skrip yang akan dimuat ke dalam pertandingan ini;
- exclude_matches - mengecualikan URL
match
dari bidang kecocokan yang cocok dengan bidang ini.
- 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.
- default_popup - path ke file HTML dengan antarmuka popup, mungkin berisi CSS dan JS.
- izin - larik untuk mengelola hak ekstensi. Ada 3 jenis hak yang dijelaskan secara rinci di sini.
- web_accessible_resources - sumber daya ekstensi yang dapat diminta halaman web, misalnya, gambar, JS, CSS, file HTML.
- 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":
- Halaman latar belakang - bagian "backend" dari ekstensi. File ditunjukkan dalam manifes oleh tombol "latar belakang".
- Halaman popup - halaman popup yang muncul ketika Anda mengklik ikon ekstensi. Dalam manifes,
browser_action
-> default_popup
. - 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.
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:
Server atau latar belakang:
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:
{
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";
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 {
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();
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(){
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 {
Karena kita tidak memerlukan API dalam skrip konten, tetapi langsung di halaman, kita melakukan dua hal:
- 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. - 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(){
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() {
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 => {
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 = {}) {
"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:
- Dalam mode ketat dengan flag menegakkanAksi, mobx melarang mengubah status secara langsung. Ini dianggap praktik yang baik untuk bekerja dalam mode ketat.
- 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";
Fungsi reaksi menarik di sini. Dia memiliki dua argumen:
- Pemilih data.
- 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";
. . locked . API .
rypto-js :
import CryptoJS from 'crypto-js'
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; }
.
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) {
, 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() {
. 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 { ...
remote
reaction
, UI.
— :
function setupApp() { ...
, . - :


.
Kesimpulan
, , . .
, .
, siemarell