diff --git a/.husky/pre-commit b/.husky/pre-commit index 3725dedca..54ba576f1 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,4 +2,4 @@ . "$(dirname -- "$0")/_/husky.sh" yarn fmt -git add -A \ No newline at end of file +git add -A diff --git a/assets/_locales/en/messages.json b/assets/_locales/en/messages.json index 596c707ce..171e2933f 100644 --- a/assets/_locales/en/messages.json +++ b/assets/_locales/en/messages.json @@ -615,11 +615,11 @@ }, "enter_new_password": { "message": "Enter your new password...", - "description": "Password input placeholder" + "description": "Password input placeholder" }, "reenter_new_password": { "message": "Re-enter your new password...", - "description": "Re-enter input placeholder" + "description": "Re-enter input placeholder" }, "enter_password": { "message": "Enter your password...", @@ -1333,12 +1333,16 @@ "message": "Click to view your last signed transaction", "description": "View last tx prompt content" }, + "view_transaction_details": { + "message": "View transaction details", + "description": "View transaction details link text" + }, "sign_item": { - "message": "Sign Item", + "message": "Sign item", "description": "Sign message popup title" }, "batch_sign_items": { - "message": "Batch Sign Items", + "message": "Sign items", "description": "Batch sign message popup title" }, "titles_signature": { @@ -1391,6 +1395,10 @@ "message": "Authorize", "description": "Authorize button label" }, + "sign_authorize_all": { + "message": "Authorize all", + "description": "Authorize all button label" + }, "sign_enter_password": { "message": "Enter password to sign transaction", "description": "Request to enter password to sign transaction" @@ -1604,7 +1612,7 @@ "description": "Lock wallet and terminate current session icon button" }, "home": { - "message":"Home", + "message":"Home", "description": "Home Button" }, "home_no_balance": { @@ -1673,11 +1681,11 @@ "message": "Toggle to receive alerts for new transactions in your wallet.", "description": "Toggle notifications description text" }, - "get_started":{ + "get_started":{ "message": "Get Started", "description": "Get started title" }, - "get_started_description":{ + "get_started_description":{ "message": "Explore the endless possibilities on Arweave and aoComputer through ArConnect", "description": "Get started description" }, @@ -1806,7 +1814,7 @@ "description": "Currency search placeholder text" }, "receive_AR_button": { - "message": "Receive AR", + "message": "Receive AR", "description": "Button text for the receive AR button" }, "search_contacts": { @@ -1925,7 +1933,7 @@ "description": "Input title for the password allowance label" }, "add": { - "message": "Add", + "message": "Add", "description" : "add button text" }, "your_contacts": { @@ -1941,24 +1949,24 @@ "description": "Error message for invalid ANS" }, "arns_added": { - "message": " ar://$ADDRESS$ added", + "message": " ar://$ADDRESS$ added", "description": "ArNS recipient added", "placeholders": { "address": { "content": "$1", "example": "Address" } - } + } }, "ans_added": { - "message": "$ADDRESS$ added", + "message": "$ADDRESS$ added", "description": "ANS recipient added", "placeholders": { "address": { "content": "$1", "example": "Address" } - } + } }, "setting_ao_support": { "message": "ao support", @@ -2110,7 +2118,7 @@ "description": "Address where payment will be made to" }, - + "subscription_recurring_amount": { "message": "Recurring payment amount", "description": "Recurring payment amount" @@ -2128,12 +2136,12 @@ "message": "Confirm Subscription", "description": "confirm subscription" }, - + "continue": { "message": "Continue", "description": "Continue" }, - + "end": { "message": "End", "description": "end" @@ -2285,5 +2293,95 @@ "new_transaction": { "message": "New transaction", "description": "New transaction description" + }, + "batchSignDataItemRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type batchSignDataItem" + }, + "connectRequestLoading": { + "message": "Connecting...", + "description": "Loading message for AuthRequest of type connect" + }, + "allowanceRequestLoading": { + "message": "Updating allowance...", + "description": "Loading message for AuthRequest of type allowance" + }, + "tokenRequestLoading": { + "message": "Adding token...", + "description": "Loading message for AuthRequest of type token" + }, + "signRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type sign" + }, + "subscriptionRequestLoading": { + "message": "Subscribing...", + "description": "Loading message for AuthRequest of type subscription" + }, + "signKeystoneRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type signKeystone" + }, + "signatureRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type signature" + }, + "signDataItemRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type signDataItem" + }, + "abortingRequestLoading": { + "message": "Aborting...", + "description": "Loading message for rejected/aborted AuthRequests" + }, + "pendingTransactionStatusAt": { + "message": "Requested", + "description": "Label to display next to the elapsed time since the AuthRequest was created" + }, + "acceptedTransactionStatusAt": { + "message": "Accepted", + "description": "Label to display next to the elapsed time since the AuthRequest was accepted" + }, + "rejectedTransactionStatusAt": { + "message": "Rejected", + "description": "Label to display next to the elapsed time since the AuthRequest was rejected" + }, + "abortedTransactionStatusAt": { + "message": "Aborted", + "description": "Label to display next to the elapsed time since the AuthRequest was aborted" + }, + "errorTransactionStatusAt": { + "message": "Failed", + "description": "Label to display next to the elapsed time since the AuthRequest was aborted" + }, + "formattedElapsedSeconds": { + "message": "$SECONDS$ seconds ago", + "description": "Format elapsed seconds", + "placeholders": { + "seconds": { + "content": "$1", + "example": "10" + } + } + }, + "formattedElapsedMinutes": { + "message": "$MINUTES$ minutes ago", + "description": "Format elapsed minutes", + "placeholders": { + "minutes": { + "content": "$1", + "example": "10" + } + } + }, + "formattedElapsedHours": { + "message": "$HOURS$ hours ago", + "description": "Format elapsed hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "10" + } + } } } diff --git a/assets/_locales/zh_CN/messages.json b/assets/_locales/zh_CN/messages.json index 6efe1cbca..c931c6d01 100644 --- a/assets/_locales/zh_CN/messages.json +++ b/assets/_locales/zh_CN/messages.json @@ -1321,6 +1321,10 @@ "message": "点击查看您最近签署的交易", "description": "View last tx prompt content" }, + "view_transaction_details": { + "message": "查看交易详情", + "description": "View transaction details link text" + }, "sign_item": { "message": "签署项目", "description": "Sign message popup title" @@ -1379,6 +1383,10 @@ "message": "授权", "description": "Authorize button label" }, + "sign_authorize_all": { + "message": "Authorize all", + "description": "Authorize all button label" + }, "sign_enter_password": { "message": "输入密码以签署交易", "description": "Request to enter password to sign transaction" @@ -2271,5 +2279,95 @@ "new_transaction": { "message": "新交易", "description": "New transaction description" + }, + "batchSignDataItemRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type batchSignDataItem" + }, + "connectRequestLoading": { + "message": "Connecting...", + "description": "Loading message for AuthRequest of type connect" + }, + "allowanceRequestLoading": { + "message": "Updating allowance...", + "description": "Loading message for AuthRequest of type allowance" + }, + "tokenRequestLoading": { + "message": "Adding token...", + "description": "Loading message for AuthRequest of type token" + }, + "signRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type sign" + }, + "subscriptionRequestLoading": { + "message": "Subscribing...", + "description": "Loading message for AuthRequest of type subscription" + }, + "signKeystoneRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type signKeystone" + }, + "signatureRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type signature" + }, + "signDataItemRequestLoading": { + "message": "Signing...", + "description": "Loading message for AuthRequest of type signDataItem" + }, + "abortingRequestLoading": { + "message": "Aborting...", + "description": "Loading message for rejected/aborted AuthRequests" + }, + "pendingTransactionStatusAt": { + "message": "Requested", + "description": "Label to display next to the elapsed time since the AuthRequest was created" + }, + "acceptedTransactionStatusAt": { + "message": "Accepted", + "description": "Label to display next to the elapsed time since the AuthRequest was accepted" + }, + "rejectedTransactionStatusAt": { + "message": "Rejected", + "description": "Label to display next to the elapsed time since the AuthRequest was rejected" + }, + "abortedTransactionStatusAt": { + "message": "Aborted", + "description": "Label to display next to the elapsed time since the AuthRequest was aborted" + }, + "errorTransactionStatusAt": { + "message": "Failed", + "description": "Label to display next to the elapsed time since the AuthRequest was aborted" + }, + "formattedElapsedSeconds": { + "message": "$SECONDS$ seconds ago", + "description": "Format elapsed seconds", + "placeholders": { + "seconds": { + "content": "$1", + "example": "10" + } + } + }, + "formattedElapsedMinutes": { + "message": "$MINUTES$ minutes ago", + "description": "Format elapsed minutes", + "placeholders": { + "minutes": { + "content": "$1", + "example": "10" + } + } + }, + "formattedElapsedHours": { + "message": "$HOURS$ hours ago", + "description": "Format elapsed hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "10" + } + } } } diff --git a/assets/placeholder.png b/assets/placeholder.png new file mode 100644 index 000000000..9c4d05cf7 Binary files /dev/null and b/assets/placeholder.png differ diff --git a/assets/popup.css b/assets/popup.css index 7ce859145..78780670b 100644 --- a/assets/popup.css +++ b/assets/popup.css @@ -55,17 +55,21 @@ :root { --defaultBackgroundColor: white; + --defaultTextColor: black; } @media (prefers-color-scheme: dark) { :root { --defaultBackgroundColor: black; + --defaultTextColor: white; } } body { margin: 0; padding: 0; + background-color: var(--backgroundColor, var(--defaultBackgroundColor, white)); + color: var(--textColor, var(--defaultTextColor, black)); } body#popup { @@ -97,6 +101,7 @@ input, select, textarea { font-family: "ManropeLocal", "Manrope VF", "Manrope", sans-serif !important; + letter-spacing: .5px; } button { diff --git a/assets/popup.js b/assets/popup.js index 7c36b0bcb..a49ef20f3 100644 --- a/assets/popup.js +++ b/assets/popup.js @@ -1,3 +1,5 @@ const backgroundColor = localStorage.getItem("ARCONNECT_THEME_BACKGROUND_COLOR"); - -if (backgroundColor) document.documentElement.style.setProperty('--backgroundColor', backgroundColor); \ No newline at end of file +const textColor = localStorage.getItem("ARCONNECT_THEME_TEXT_COLOR"); + +if (backgroundColor) document.documentElement.style.setProperty('--backgroundColor', backgroundColor); +if (textColor) document.documentElement.style.setProperty('--textColor', textColor); diff --git a/index.html b/index.html new file mode 100644 index 000000000..789fde4aa --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + ArConnect Embedded Wallet + + + + +
+
+ + + + diff --git a/package.json b/package.json index 23c9ac159..61b19a4b0 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ ] }, "dependencies": { - "@arconnect/components": "^0.3.11", + "@arconnect/components": "^1.0.0", "@arconnect/keystone-sdk": "^0.0.5", "@arconnect/warp-dre": "^0.0.1", "@arconnect/webext-bridge": "^5.0.6", @@ -60,6 +60,7 @@ "@permaweb/aoconnect": "^0.0.55", "@plasmohq/storage": "^1.7.2", "@segment/analytics-next": "^1.53.2", + "@swyg/corre": "^1.0.4", "@untitled-ui/icons-react": "^0.1.1", "ao-tokens": "^0.0.4", "ar-gql": "1.2.9", @@ -76,7 +77,6 @@ "js-confetti": "^0.11.0", "mitt": "^3.0.0", "nanoid": "^4.0.0", - "path-to-regexp": "^6.2.1", "plimit-lit": "^3.0.1", "pretty-bytes": "^6.0.0", "qrcode.react": "^3.1.0", @@ -92,7 +92,7 @@ "uuid": "^9.0.0", "warp-contracts": "^1.2.13", "webextension-polyfill": "^0.10.0", - "wouter": "^2.8.0-alpha.2" + "wouter": "^3.3.5" }, "devDependencies": { "@changesets/cli": "^2.27.1", diff --git a/shim.d.ts b/shim.d.ts index 2921c8afc..db972077d 100644 --- a/shim.d.ts +++ b/shim.d.ts @@ -3,17 +3,66 @@ import type { DisplayTheme } from "@arconnect/components"; import type { Chunk } from "~api/modules/sign/chunks"; import type { InjectedEvents } from "~utils/events"; import "styled-components"; +import type { + AuthRequestMessageData, + AuthResult +} from "~utils/auth/auth.types"; declare module "@arconnect/webext-bridge" { export interface ProtocolMap { + /** + * `api/foreground/foreground-setup-wallet-sdk.ts` use `postMessage()` to send `arweaveWallet.*` calls that are + * received in `contents/api.ts`, which then sends them to the background using `sendMessage()`. + */ api_call: ProtocolWithReturn; - auth_result: AuthResult; + + /** + * `dispatch.foreground.ts` and `sign.foreground.ts` use `sendChunk()` to send chunks to the background. + */ + chunk: ProtocolWithReturn, ApiResponse>; + + /** + * `createAuthPopup()` in `auth.utils.ts` sends `auth_request` messages from the background to the auth popup, which + * are received in `auth.provider.ts`. + */ + auth_request: AuthRequestMessageData; + + /** + * `auth.hook.ts` uses `auth_result` messages (calling `replyToAuthRequest()`) to reply to the `AuthRequest`s. + */ + auth_result: AuthResult; + + /** + * `signAuth()` in `sign_auth.ts` uses `auth_chunk` to send chunked transactions or binary data from the background + * to the auth popup. + */ + auth_chunk: Chunk; + + /** + * The background sends `auth_tab_closed` messages to notify the auth popup of closed tabs. + */ + auth_tab_closed: number; + + /** + * The background sends `auth_tab_reloaded` messages to notify the auth popup of reloaded tabs. + */ + auth_tab_reloaded: number; + + /** + * The background sends `auth_active_wallet_change` messages to notify the auth popup of active wallet changes. + */ + auth_active_wallet_change: number; + + /** + * The background sends `auth_app_disconnected` messages to notify the auth popup of disconnected apps. + */ + auth_app_disconnected: number; + + // OTHER: + switch_wallet_event: string | null; copy_address: string; - chunk: ProtocolWithReturn, ApiResponse>; event: Event; - auth_listening: number; - auth_chunk: Chunk; ar_protocol: ProtocolWithReturn<{ url: string }, { url: sting }>; } } @@ -28,13 +77,6 @@ interface ApiResponse extends ApiCall { error?: boolean; } -interface AuthResult { - type: string; - authID: string; - error?: boolean; - data?: DataType; -} - interface Event { name: keyof InjectedEvents; value: unknown; diff --git a/src/api/background.ts b/src/api/background.ts deleted file mode 100644 index 2b5df2526..000000000 --- a/src/api/background.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Module } from "./module"; - -// import modules -import permissionsModule from "./modules/permissions"; -import permissions from "./modules/permissions/permissions.background"; -import activeAddressModule from "./modules/active_address"; -import activeAddress from "./modules/active_address/active_address.background"; -import allAddressesModule from "./modules/all_addresses"; -import allAddresses from "./modules/all_addresses/all_addresses.background"; -import publicKeyModule from "./modules/public_key"; -import publicKey from "./modules/public_key/public_key.background"; -import walletNamesModule from "./modules/wallet_names"; -import walletNames from "./modules/wallet_names/wallet_names.background"; -import arweaveConfigModule from "./modules/arweave_config"; -import arweaveConfig from "./modules/arweave_config/arweave_config.background"; -import disconnectModule from "./modules/disconnect"; -import disconnect from "./modules/disconnect/disconnect.background"; -import connectModule from "./modules/connect"; -import connect from "./modules/connect/connect.background"; -import signModule from "./modules/sign"; -import sign from "./modules/sign/sign.background"; -import dispatchModule from "./modules/dispatch"; -import dispatch from "./modules/dispatch/dispatch.background"; -import encryptModule from "./modules/encrypt"; -import encrypt from "./modules/encrypt/encrypt.background"; -import decryptModule from "./modules/decrypt"; -import decrypt from "./modules/decrypt/decrypt.background"; -import signatureModule from "./modules/signature"; -import signature from "./modules/signature/signature.background"; -import addTokenModule from "./modules/add_token"; -import addToken from "./modules/add_token/add_token.background"; -import isTokenAddedModule from "./modules/is_token_added"; -import isTokenAdded from "./modules/is_token_added/is_token_added.background"; -import signMessageModule from "./modules/sign_message"; -import signMessage from "./modules/sign_message/sign_message.background"; -import privateHashModule from "./modules/private_hash"; -import privateHash from "./modules/private_hash/private_hash.background"; -import verifyMessageModule from "./modules/verify_message"; -import verifyMessage from "./modules/verify_message/verify_message.background"; -import signDataItemModule from "./modules/sign_data_item"; -import signDataItem from "./modules/sign_data_item/sign_data_item.background"; -import batchSignDataItemModule from "./modules/batch_sign_data_item"; -import batchSignDataItem from "./modules/batch_sign_data_item/batch_sign_data_item.background"; -import subscriptionModule from "./modules/subscription"; -import subscription from "./modules/subscription/subscription.background"; -import userTokensModule from "./modules/user_tokens"; -import userTokens from "./modules/user_tokens/user_tokens.background"; -import tokenBalanceModule from "./modules/token_balance"; -import tokenBalance from "./modules/token_balance/token_balance.background"; - -/** Background modules */ -const modules: BackgroundModule[] = [ - { ...permissionsModule, function: permissions }, - { ...activeAddressModule, function: activeAddress }, - { ...allAddressesModule, function: allAddresses }, - { ...publicKeyModule, function: publicKey }, - { ...walletNamesModule, function: walletNames }, - { ...arweaveConfigModule, function: arweaveConfig }, - { ...disconnectModule, function: disconnect }, - { ...connectModule, function: connect }, - { ...addTokenModule, function: addToken }, - { ...isTokenAddedModule, function: isTokenAdded }, - { ...signModule, function: sign }, - { ...dispatchModule, function: dispatch }, - { ...encryptModule, function: encrypt }, - { ...decryptModule, function: decrypt }, - { ...signatureModule, function: signature }, - { ...signMessageModule, function: signMessage }, - { ...privateHashModule, function: privateHash }, - { ...verifyMessageModule, function: verifyMessage }, - { ...signDataItemModule, function: signDataItem }, - { ...subscriptionModule, function: subscription }, - { ...userTokensModule, function: userTokens }, - { ...tokenBalanceModule, function: tokenBalance }, - { ...batchSignDataItemModule, function: batchSignDataItem } -]; - -export default modules; - -/** Extended module interface */ -interface BackgroundModule extends Module { - function: ModuleFunction; -} - -/** - * Extended module function - */ -export type ModuleFunction = ( - appData: ModuleAppData, - ...params: any[] -) => Promise | ResultType; - -export interface ModuleAppData { - appURL: string; - favicon?: string; -} diff --git a/src/api/background/background-modules.ts b/src/api/background/background-modules.ts new file mode 100644 index 000000000..d0ed50630 --- /dev/null +++ b/src/api/background/background-modules.ts @@ -0,0 +1,95 @@ +import type { Module } from "../module"; + +// import modules +import permissionsModule from "../modules/permissions"; +import permissions from "../modules/permissions/permissions.background"; +import activeAddressModule from "../modules/active_address"; +import activeAddress from "../modules/active_address/active_address.background"; +import allAddressesModule from "../modules/all_addresses"; +import allAddresses from "../modules/all_addresses/all_addresses.background"; +import publicKeyModule from "../modules/public_key"; +import publicKey from "../modules/public_key/public_key.background"; +import walletNamesModule from "../modules/wallet_names"; +import walletNames from "../modules/wallet_names/wallet_names.background"; +import arweaveConfigModule from "../modules/arweave_config"; +import arweaveConfig from "../modules/arweave_config/arweave_config.background"; +import disconnectModule from "../modules/disconnect"; +import disconnect from "../modules/disconnect/disconnect.background"; +import connectModule from "../modules/connect"; +import connect from "../modules/connect/connect.background"; +import signModule from "../modules/sign"; +import sign from "../modules/sign/sign.background"; +import dispatchModule from "../modules/dispatch"; +import dispatch from "../modules/dispatch/dispatch.background"; +import encryptModule from "../modules/encrypt"; +import encrypt from "../modules/encrypt/encrypt.background"; +import decryptModule from "../modules/decrypt"; +import decrypt from "../modules/decrypt/decrypt.background"; +import signatureModule from "../modules/signature"; +import signature from "../modules/signature/signature.background"; +import addTokenModule from "../modules/add_token"; +import addToken from "../modules/add_token/add_token.background"; +import isTokenAddedModule from "../modules/is_token_added"; +import isTokenAdded from "../modules/is_token_added/is_token_added.background"; +import signMessageModule from "../modules/sign_message"; +import signMessage from "../modules/sign_message/sign_message.background"; +import privateHashModule from "../modules/private_hash"; +import privateHash from "../modules/private_hash/private_hash.background"; +import verifyMessageModule from "../modules/verify_message"; +import verifyMessage from "../modules/verify_message/verify_message.background"; +import signDataItemModule from "../modules/sign_data_item"; +import signDataItem from "../modules/sign_data_item/sign_data_item.background"; +import batchSignDataItemModule from "../modules/batch_sign_data_item"; +import batchSignDataItem from "../modules/batch_sign_data_item/batch_sign_data_item.background"; +import subscriptionModule from "../modules/subscription"; +import subscription from "../modules/subscription/subscription.background"; +import userTokensModule from "../modules/user_tokens"; +import userTokens from "../modules/user_tokens/user_tokens.background"; +import tokenBalanceModule from "../modules/token_balance"; +import tokenBalance from "../modules/token_balance/token_balance.background"; + +export interface ModuleAppData { + tabID: number; + url: string; + favicon?: string; +} + +/** + * Extended module function + */ +export type BackgroundModuleFunction = ( + appData: ModuleAppData, + ...params: any[] +) => Promise | ResultType; + +/** Extended module interface */ +export interface BackgroundModule extends Module { + function: BackgroundModuleFunction; +} + +/** Background modules */ +export const backgroundModules: BackgroundModule[] = [ + { ...permissionsModule, function: permissions }, + { ...activeAddressModule, function: activeAddress }, + { ...allAddressesModule, function: allAddresses }, + { ...publicKeyModule, function: publicKey }, + { ...walletNamesModule, function: walletNames }, + { ...arweaveConfigModule, function: arweaveConfig }, + { ...disconnectModule, function: disconnect }, + { ...connectModule, function: connect }, + { ...addTokenModule, function: addToken }, + { ...isTokenAddedModule, function: isTokenAdded }, + { ...signModule, function: sign }, + { ...dispatchModule, function: dispatch }, + { ...encryptModule, function: encrypt }, + { ...decryptModule, function: decrypt }, + { ...signatureModule, function: signature }, + { ...signMessageModule, function: signMessage }, + { ...privateHashModule, function: privateHash }, + { ...verifyMessageModule, function: verifyMessage }, + { ...signDataItemModule, function: signDataItem }, + { ...subscriptionModule, function: subscription }, + { ...userTokensModule, function: userTokens }, + { ...tokenBalanceModule, function: tokenBalance }, + { ...batchSignDataItemModule, function: batchSignDataItem } +]; diff --git a/src/api/background/background-setup.ts b/src/api/background/background-setup.ts new file mode 100644 index 000000000..c99cdb17e --- /dev/null +++ b/src/api/background/background-setup.ts @@ -0,0 +1,115 @@ +import { onMessage } from "@arconnect/webext-bridge"; +import handleFeeAlarm from "~api/modules/sign/fee"; +import { ExtensionStorage } from "~utils/storage"; +import browser from "webextension-polyfill"; +import { handleApiCallMessage } from "~api/background/handlers/message/api-call-message/api-call-message.handler"; +import { handleChunkMessage } from "~api/background/handlers/message/chunk-message/chunk-message.handler"; +import { handleInstall } from "~api/background/handlers/browser/install/install.handler"; +import { handleProtocol } from "~api/background/handlers/browser/protocol/protocol.handler"; +import { handleActiveAddressChange } from "~api/background/handlers/storage/active-address-change/active-address-change.handler"; +import { handleWalletsChange } from "~api/background/handlers/storage/wallet-change/wallet-change.handler"; +import { handleAppsChange } from "~api/background/handlers/storage/apps-change/app-change.handler"; +import { handleAppConfigChange } from "~api/background/handlers/storage/app-config-change/app-config-change.handler"; +import { handleTrackBalanceAlarm } from "~api/background/handlers/alarms/track-balance/track-balance-alarm.handler"; +import { handleGetPrinters } from "~api/background/handlers/browser/printer/get-printers/get-printers.handler"; +import { handleGetCapabilities } from "~api/background/handlers/browser/printer/get-capabilities/get-capabilities.handler"; +import { handlePrint } from "~api/background/handlers/browser/printer/print/print.handler"; +import { handleNotificationsAlarm } from "~api/background/handlers/alarms/notifications/notifications-alarm.handler"; +import { handleSubscriptionsAlarm } from "~api/background/handlers/alarms/subscriptions/subscriptions-alarm.handler"; +import { handleAoTokenCacheAlarm } from "~api/background/handlers/alarms/ao-tokens-cache/ao-tokens-cache-alarm.handler"; +import { handleGatewayUpdateAlarm } from "~api/background/handlers/alarms/gateway-update/gateway-update-alarm.handler"; +import { handleSyncLabelsAlarm } from "~api/background/handlers/alarms/sync-labels/sync-labels-alarm.handler"; +import { handleWindowClose } from "~api/background/handlers/browser/window-close/window-close.handler"; +import { handleKeyRemovalAlarm } from "~api/background/handlers/alarms/key-removal/key-removal-alarm.handler"; +import { handleAoTokensImportAlarm } from "~api/background/handlers/alarms/ao-tokens-import/ao-tokens-import-alarm.handler"; +import { + handleTabClosed, + handleTabUpdate +} from "~api/background/handlers/browser/tabs/tabs.handler"; +import { log, LOG_GROUP } from "~utils/log/log.utils"; + +export function setupBackgroundService() { + log( + LOG_GROUP.SETUP, + `background-setup.ts > setupBackgroundService(PLASMO_PUBLIC_APP_TYPE = "${process.env.PLASMO_PUBLIC_APP_TYPE}")` + ); + + // MESSAGES: + // Watch for API call and chunk messages: + onMessage("api_call", handleApiCallMessage); + onMessage("chunk", handleChunkMessage); + + // LIFECYCLE: + + // Open welcome page on extension install. + browser.runtime.onInstalled.addListener(handleInstall); + + // ALARMS: + browser.alarms.onAlarm.addListener(handleNotificationsAlarm); + browser.alarms.onAlarm.addListener(handleSubscriptionsAlarm); + browser.alarms.onAlarm.addListener(handleTrackBalanceAlarm); + browser.alarms.onAlarm.addListener(handleGatewayUpdateAlarm); + browser.alarms.onAlarm.addListener(handleSyncLabelsAlarm); + browser.alarms.onAlarm.addListener(handleKeyRemovalAlarm); + browser.alarms.onAlarm.addListener(handleAoTokenCacheAlarm); + browser.alarms.onAlarm.addListener(handleAoTokensImportAlarm); + + // handle keep alive alarm + browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "keep-alive") { + console.log("keep alive alarm"); + } + }); + + // handle fee alarm (send fees asynchronously) + // browser.alarms.onAlarm.addListener(handleFeeAlarm); + + // STORAGE: + + // watch for active address changes / app + // list changes + // and send them to the content script to + // fire the wallet switch event + ExtensionStorage.watch({ + apps: handleAppsChange, + active_address: handleActiveAddressChange, + wallets: handleWalletsChange + }); + + // listen for app config updates + // `ExtensionStorage.watch` requires a callbackMap param, so this cannot be done using `ExtensionStorage` directly. + browser.storage.onChanged.addListener(handleAppConfigChange); + + if (process.env.PLASMO_PUBLIC_APP_TYPE !== "extension") return; + + // ONLY BROWSER EXTENSION BELOW THIS LINE: + + // When the last window connected to the extension is closed, the decryption key will be removed from memory. This is no needed in the embedded wallet because + // each wallet instance will be removed automatically when its tab/window is closed. + browser.windows.onRemoved.addListener(handleWindowClose); + + // handle tab change (icon, context menus) + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + handleTabUpdate(tabId, changeInfo); + }); + browser.tabs.onActivated.addListener(({ tabId }) => { + handleTabUpdate(tabId); + }); + browser.tabs.onRemoved.addListener(handleTabClosed); + + // handle ar:// protocol + browser.webNavigation.onBeforeNavigate.addListener(handleProtocol); + + // print to the permaweb (only on chrome) + if (typeof chrome !== "undefined") { + chrome.printerProvider.onGetCapabilityRequested.addListener( + handleGetCapabilities + ); + + chrome.printerProvider.onGetPrintersRequested.addListener( + handleGetPrinters + ); + + chrome.printerProvider.onPrintRequested.addListener(handlePrint); + } +} diff --git a/src/api/background/handlers/alarms/ao-tokens-cache/ao-tokens-cache-alarm.handler.ts b/src/api/background/handlers/alarms/ao-tokens-cache/ao-tokens-cache-alarm.handler.ts new file mode 100644 index 000000000..bec7bf48f --- /dev/null +++ b/src/api/background/handlers/alarms/ao-tokens-cache/ao-tokens-cache-alarm.handler.ts @@ -0,0 +1,52 @@ +import { dryrun } from "@permaweb/aoconnect"; +import { ExtensionStorage } from "~utils/storage"; +import type { Alarms } from "webextension-polyfill"; +import { Id, Owner, type TokenInfo } from "~tokens/aoTokens/ao"; +import { timeoutPromise } from "~utils/promises/timeout"; +import { getTokenInfoFromData } from "~tokens/aoTokens/router"; + +/** + * Alarm handler for syncing ao tokens + */ +export const handleAoTokenCacheAlarm = async (alarmInfo?: Alarms.Alarm) => { + if (alarmInfo && !alarmInfo.name.startsWith("update_ao_tokens")) return; + + const aoTokens = (await ExtensionStorage.get("ao_tokens")) || []; + + const updatedTokens = [...aoTokens]; + + for (const token of aoTokens) { + try { + const res = await timeoutPromise( + dryrun({ + Id, + Owner, + process: token.processId, + tags: [{ name: "Action", value: "Info" }] + }), + 6000 + ); + + if (res.Messages && Array.isArray(res.Messages)) { + const tokenInfo = getTokenInfoFromData(res, token.processId); + const updatedToken = { + ...tokenInfo, + lastUpdated: new Date().toISOString() + }; + + if (updatedToken) { + const index = updatedTokens.findIndex( + (t) => t.processId === token.processId + ); + + if (index !== -1) { + updatedTokens[index] = { ...updatedTokens[index], ...updatedToken }; + } + } + } + } catch (err) { + console.error(`Failed to update token with id ${token.processId}:`, err); + } + } + await ExtensionStorage.set("ao_tokens", updatedTokens); +}; diff --git a/src/api/background/handlers/alarms/ao-tokens-import/ao-tokens-import-alarm.handler.ts b/src/api/background/handlers/alarms/ao-tokens-import/ao-tokens-import-alarm.handler.ts new file mode 100644 index 000000000..47d6b6cce --- /dev/null +++ b/src/api/background/handlers/alarms/ao-tokens-import/ao-tokens-import-alarm.handler.ts @@ -0,0 +1,116 @@ +import Arweave from "arweave"; +import type { Alarms } from "webextension-polyfill"; +import { + getAoTokens, + getAoTokensCache, + getAoTokensAutoImportRestrictedIds +} from "~tokens"; +import { getTokenInfo } from "~tokens/aoTokens/router"; +import { + AO_TOKENS, + AO_TOKENS_AUTO_IMPORT_RESTRICTED_IDS, + AO_TOKENS_IMPORT_TIMESTAMP, + gateway, + getNoticeTransactions, + verifyCollectiblesType +} from "~tokens/aoTokens/sync"; +import { withRetry } from "~utils/promises/retry"; +import { timeoutPromise } from "~utils/promises/timeout"; +import { ExtensionStorage } from "~utils/storage"; +import { getActiveAddress } from "~wallets"; + +/** + * Import AO Tokens + */ +export async function handleAoTokensImportAlarm(alarm: Alarms.Alarm) { + if (alarm?.name !== "import_ao_tokens") return; + + try { + const activeAddress = await getActiveAddress(); + + let [aoTokens, aoTokensCache, removedTokenIds = []] = await Promise.all([ + getAoTokens(), + getAoTokensCache(), + getAoTokensAutoImportRestrictedIds() + ]); + + let aoTokensIds = new Set(aoTokens.map(({ processId }) => processId)); + const aoTokensCacheIds = new Set( + aoTokensCache.map(({ processId }) => processId) + ); + let tokenIdstoExclude = new Set([...aoTokensIds, ...removedTokenIds]); + const walletTokenIds = new Set([...tokenIdstoExclude, ...aoTokensCacheIds]); + + const arweave = new Arweave(gateway); + const { processIds } = await getNoticeTransactions( + arweave, + activeAddress, + Array.from(walletTokenIds) + ); + + const newProcessIds = Array.from( + new Set([...processIds, ...aoTokensCacheIds]) + ).filter((processId) => !tokenIdstoExclude.has(processId)); + + if (newProcessIds.length === 0) { + console.log("No new ao tokens found!"); + return; + } + + const promises = newProcessIds + .filter((processId) => !aoTokensCacheIds.has(processId)) + .map((processId) => + withRetry(async () => { + const token = await timeoutPromise(getTokenInfo(processId), 3000); + return { ...token, processId }; + }, 2) + ); + const results = await Promise.allSettled(promises); + + const tokens = []; + const tokensToRestrict = []; + results.forEach((result) => { + if (result.status === "fulfilled") { + const token = result.value; + if (token.Ticker) { + tokens.push(token); + } else if (!removedTokenIds.includes(token.processId)) { + tokensToRestrict.push(token); + } + } + }); + + const updatedTokens = [...aoTokensCache, ...tokens]; + + aoTokens = await getAoTokens(); + aoTokensIds = new Set(aoTokens.map(({ processId }) => processId)); + tokenIdstoExclude = new Set([...aoTokensIds, ...removedTokenIds]); + + if (tokensToRestrict.length > 0) { + removedTokenIds.push( + ...tokensToRestrict.map(({ processId }) => processId) + ); + await ExtensionStorage.set( + AO_TOKENS_AUTO_IMPORT_RESTRICTED_IDS, + removedTokenIds + ); + } + + let newTokens = updatedTokens.filter( + (token) => !tokenIdstoExclude.has(token.processId) + ); + if (newTokens.length === 0) return; + + // Verify collectibles type + newTokens = await verifyCollectiblesType(newTokens, arweave); + + newTokens.forEach((token) => aoTokens.push(token)); + await ExtensionStorage.set(AO_TOKENS, aoTokens); + + console.log("Imported ao tokens!"); + } catch (error: any) { + console.log("Error importing tokens: ", error?.message); + } finally { + await ExtensionStorage.set(AO_TOKENS_IMPORT_TIMESTAMP, 0); + } +} diff --git a/src/api/background/handlers/alarms/gateway-update/gateway-update-alarm.handler.ts b/src/api/background/handlers/alarms/gateway-update/gateway-update-alarm.handler.ts new file mode 100644 index 000000000..d77a217ef --- /dev/null +++ b/src/api/background/handlers/alarms/gateway-update/gateway-update-alarm.handler.ts @@ -0,0 +1,57 @@ +import { extractGarItems, pingUpdater } from "~lib/wayfinder"; +import { type Alarms } from "webextension-polyfill"; +import { AOProcess } from "~lib/ao"; +import { AO_ARNS_PROCESS } from "~lib/arns"; +import type { + GatewayAddressRegistryItem, + GatewayAddressRegistryItemData, + PaginatedResult +} from "~gateways/types"; +import { + RETRY_ALARM, + scheduleGatewayUpdate, + UPDATE_ALARM, + updateGatewayCache +} from "~gateways/cache"; + +/** + * Gateway cache update call. Usually called by an alarm, + * but will also be executed on install. + */ +export async function handleGatewayUpdateAlarm(alarm?: Alarms.Alarm) { + if (alarm && ![UPDATE_ALARM, RETRY_ALARM].includes(alarm.name)) { + return; + } + + let garItemsWithChecks: GatewayAddressRegistryItem[] = []; + + try { + // fetch cache + const ArIO = new AOProcess({ processId: AO_ARNS_PROCESS }); + + const gatewaysResult = await ArIO.read< + PaginatedResult + >({ + tags: [ + { name: "Action", value: "Paginated-Gateways" }, + { name: "Sort-By", value: "operatorStake" }, + { name: "Sort-Order", value: "desc" }, + { name: "Limit", value: "100" } + ] + }); + + const garItems = extractGarItems(gatewaysResult.items); + + // healtcheck + await pingUpdater(garItems, (nextGarItemsWithChecks) => { + garItemsWithChecks = nextGarItemsWithChecks; + }); + + await updateGatewayCache(garItemsWithChecks); + } catch (err) { + console.log("err in handle", err); + + // schedule to try again + await scheduleGatewayUpdate(true); + } +} diff --git a/src/api/background/handlers/alarms/key-removal/key-removal-alarm.handler.ts b/src/api/background/handlers/alarms/key-removal/key-removal-alarm.handler.ts new file mode 100644 index 000000000..a8c836f07 --- /dev/null +++ b/src/api/background/handlers/alarms/key-removal/key-removal-alarm.handler.ts @@ -0,0 +1,16 @@ +import type { Alarms } from "webextension-polyfill"; +import { getDecryptionKey, removeDecryptionKey } from "~wallets/auth"; + +/** + * Listener for the key removal alarm + */ +export async function handleKeyRemovalAlarm(alarm: Alarms.Alarm) { + if (alarm.name !== "remove_decryption_key_scheduled") return; + + // check if there is a decryption key + const decryptionKey = await getDecryptionKey(); + if (!decryptionKey) return; + + // remove the decryption key + await removeDecryptionKey(); +} diff --git a/src/notifications/api.ts b/src/api/background/handlers/alarms/notifications/notifications-alarm.handler.ts similarity index 59% rename from src/notifications/api.ts rename to src/api/background/handlers/alarms/notifications/notifications-alarm.handler.ts index 81021229b..14713a894 100644 --- a/src/notifications/api.ts +++ b/src/api/background/handlers/alarms/notifications/notifications-alarm.handler.ts @@ -2,56 +2,18 @@ import { ExtensionStorage } from "~utils/storage"; import { getActiveAddress } from "~wallets"; import iconUrl from "url:/assets/icon512.png"; import browser, { type Alarms } from "webextension-polyfill"; -import { gql } from "~gateways/api"; -import { suggestedGateways } from "~gateways/gateway"; +import { arNotificationsHandler } from "~api/background/handlers/alarms/notifications/notifications-alarm.utils"; import { + ALL_AR_RECEIVER_QUERY, + ALL_AR_SENT_QUERY, AO_RECEIVER_QUERY, AO_SENT_QUERY, AR_RECEIVER_QUERY, - AR_SENT_QUERY, - ALL_AR_RECEIVER_QUERY, - ALL_AR_SENT_QUERY, - combineAndSortTransactions, - processTransactions -} from "./utils"; -import BigNumber from "bignumber.js"; - -export type RawTransaction = { - node: { - id: string; - recipient: string; - owner: { - address: string; - }; - quantity: { - ar: string; - }; - fee: { - ar: string; - }; - block: { - timestamp: number; - height: number; - }; - tags: Array<{ - name: string; - value: string; - }>; - }; -}; - -export type Transaction = RawTransaction & { - transactionType: string; - quantity: string; - isAo?: boolean; - tokenId?: string; - warpContract?: boolean; -}; + AR_SENT_QUERY +} from "~notifications/utils"; -type ArNotificationsHandlerReturnType = [Transaction[], number, any[]]; - -export async function notificationsHandler(alarmInfo?: Alarms.Alarm) { - if (alarmInfo && !alarmInfo.name.startsWith("notifications")) return; +export async function handleNotificationsAlarm(alarm?: Alarms.Alarm) { + if (alarm && !alarm.name.startsWith("notifications")) return; const notificationSetting: boolean = await ExtensionStorage.get( "setting_notifications" @@ -182,65 +144,3 @@ export async function notificationsHandler(alarmInfo?: Alarms.Alarm) { console.error("Error updating notifications:", err); } } - -const arNotificationsHandler = async ( - address: string, - lastStoredHeight: number, - notificationSetting: boolean, - queriesConfig: { - query: string; - variables: Record; - isAllTxns?: boolean; - }[] -): Promise => { - try { - let transactionDiff = []; - - const queries = queriesConfig.map((config) => - gql(config.query, config.variables, suggestedGateways[1]) - ); - let responses = await Promise.all(queries); - responses = responses.map((response, index) => { - if ( - typeof queriesConfig[index].isAllTxns === "boolean" && - !queriesConfig[index].isAllTxns - ) { - response.data.transactions.edges = - response.data.transactions.edges.filter((edge) => - BigNumber(edge.node.quantity.ar).gt(0) - ); - } - return response; - }); - - const combinedTransactions = combineAndSortTransactions(responses); - - const enrichedTransactions = processTransactions( - combinedTransactions, - address - ); - - const newMaxHeight = Math.max( - ...enrichedTransactions - .filter((tx) => tx.node.block) // Filter out transactions without a block - .map((tx) => tx.node.block.height) - ); - // filters out transactions that are older than last stored height, - if (newMaxHeight !== lastStoredHeight) { - const newTransactions = enrichedTransactions.filter( - (transaction) => - transaction.node.block && - transaction.node.block.height > lastStoredHeight - ); - - // if it's the first time loading notifications, don't send a message && notifications are enabled - if (lastStoredHeight !== 0 && notificationSetting) { - await ExtensionStorage.set("new_notifications", true); - transactionDiff = newTransactions; - } - } - return [enrichedTransactions, newMaxHeight, transactionDiff]; - } catch (err) { - console.log("err", err); - } -}; diff --git a/src/api/background/handlers/alarms/notifications/notifications-alarm.utils.ts b/src/api/background/handlers/alarms/notifications/notifications-alarm.utils.ts new file mode 100644 index 000000000..2362b97d3 --- /dev/null +++ b/src/api/background/handlers/alarms/notifications/notifications-alarm.utils.ts @@ -0,0 +1,101 @@ +import BigNumber from "bignumber.js"; +import { gql } from "~gateways/api"; +import { suggestedGateways } from "~gateways/gateway"; +import { + combineAndSortTransactions, + processTransactions +} from "~notifications/utils"; +import { ExtensionStorage } from "~utils/storage"; + +export type RawTransaction = { + node: { + id: string; + recipient: string; + owner: { + address: string; + }; + quantity: { + ar: string; + }; + block: { + timestamp: number; + height: number; + }; + tags: Array<{ + name: string; + value: string; + }>; + }; +}; + +export type Transaction = RawTransaction & { + transactionType: string; + quantity: string; + isAo?: boolean; + tokenId?: string; + warpContract?: boolean; +}; + +type ArNotificationsHandlerReturnType = [Transaction[], number, any[]]; + +export async function arNotificationsHandler( + address: string, + lastStoredHeight: number, + notificationSetting: boolean, + queriesConfig: { + query: string; + variables: Record; + isAllTxns?: boolean; + }[] +): Promise { + try { + let transactionDiff = []; + + const queries = queriesConfig.map((config) => + gql(config.query, config.variables, suggestedGateways[1]) + ); + let responses = await Promise.all(queries); + responses = responses.map((response, index) => { + if ( + typeof queriesConfig[index].isAllTxns === "boolean" && + !queriesConfig[index].isAllTxns + ) { + response.data.transactions.edges = + response.data.transactions.edges.filter((edge) => + BigNumber(edge.node.quantity.ar).gt(0) + ); + } + return response; + }); + + const combinedTransactions = combineAndSortTransactions(responses); + + const enrichedTransactions = processTransactions( + combinedTransactions, + address + ); + + const newMaxHeight = Math.max( + ...enrichedTransactions + .filter((tx) => tx.node.block) // Filter out transactions without a block + .map((tx) => tx.node.block.height) + ); + // filters out transactions that are older than last stored height, + if (newMaxHeight !== lastStoredHeight) { + const newTransactions = enrichedTransactions.filter( + (transaction) => + transaction.node.block && + transaction.node.block.height > lastStoredHeight + ); + + // if it's the first time loading notifications, don't send a message && notifications are enabled + if (lastStoredHeight !== 0 && notificationSetting) { + await ExtensionStorage.set("new_notifications", true); + transactionDiff = newTransactions; + } + } + return [enrichedTransactions, newMaxHeight, transactionDiff]; + } catch (err) { + console.log("err", err); + } +} diff --git a/src/subscriptions/api.ts b/src/api/background/handlers/alarms/subscriptions/subscriptions-alarm.handler.ts similarity index 81% rename from src/subscriptions/api.ts rename to src/api/background/handlers/alarms/subscriptions/subscriptions-alarm.handler.ts index 2c431a96a..f9855ae9e 100644 --- a/src/subscriptions/api.ts +++ b/src/api/background/handlers/alarms/subscriptions/subscriptions-alarm.handler.ts @@ -1,9 +1,8 @@ -import type { SubscriptionData } from "./subscription"; import { addSubscription, getSubscriptionData } from "~subscriptions"; -import { ExtensionStorage } from "~utils/storage"; import { getActiveAddress } from "~wallets"; -import { handleSubscriptionPayment } from "./payments"; import type { Alarms } from "webextension-polyfill"; +import { handleSubscriptionPayment } from "~subscriptions/payments"; +import type { SubscriptionData } from "~subscriptions/subscription"; /** * + fetch subscription auto withdrawal allowance @@ -13,7 +12,7 @@ import type { Alarms } from "webextension-polyfill"; * + notify user of manual payments */ -export async function subscriptionsHandler(alarmInfo?: Alarms.Alarm) { +export async function handleSubscriptionsAlarm(alarmInfo?: Alarms.Alarm) { if (alarmInfo && !alarmInfo.name.startsWith("subscription-alarm-")) return; const prefixLength = "subscription-alarm-".length; diff --git a/src/api/background/handlers/alarms/sync-labels/sync-labels-alarm.handler.ts b/src/api/background/handlers/alarms/sync-labels/sync-labels-alarm.handler.ts new file mode 100644 index 000000000..c593ea466 --- /dev/null +++ b/src/api/background/handlers/alarms/sync-labels/sync-labels-alarm.handler.ts @@ -0,0 +1,38 @@ +import { getAnsProfile, type AnsUser } from "~lib/ans"; +import type { Alarms } from "webextension-polyfill"; +import { ExtensionStorage } from "~utils/storage"; +import { getWallets } from "~wallets"; + +/** + * Sync nicknames with ANS labels + */ +export async function handleSyncLabelsAlarm(alarm?: Alarms.Alarm) { + // check alarm name if called from an alarm + if (alarm && alarm.name !== "sync_labels") { + return; + } + + // get wallets + const wallets = await getWallets(); + + if (wallets.length === 0) return; + + // get profiles + const profiles = (await getAnsProfile( + wallets.map((w) => w.address) + )) as AnsUser[]; + + const find = (addr: string) => + profiles.find((w) => w.user === addr)?.currentLabel; + + // save updated wallets + await ExtensionStorage.set( + "wallets", + wallets.map((wallet) => ({ + ...wallet, + nickname: find(wallet.address) + ? find(wallet.address) + ".ar" + : wallet.nickname + })) + ); +} diff --git a/src/api/background/handlers/alarms/track-balance/track-balance-alarm.handler.ts b/src/api/background/handlers/alarms/track-balance/track-balance-alarm.handler.ts new file mode 100644 index 000000000..9c4fcbba9 --- /dev/null +++ b/src/api/background/handlers/alarms/track-balance/track-balance-alarm.handler.ts @@ -0,0 +1,46 @@ +import { getWallets } from "~wallets"; +import Arweave from "arweave"; +import { defaultGateway } from "~gateways/gateway"; +import browser, { type Alarms } from "webextension-polyfill"; +import BigNumber from "bignumber.js"; +import { + EventType, + setToStartOfNextMonth, + trackDirect +} from "~utils/analytics"; + +export async function handleTrackBalanceAlarm(alarmInfo?: Alarms.Alarm) { + if (alarmInfo && !alarmInfo.name.startsWith("track-balance")) return; + + const wallets = await getWallets(); + const arweave = new Arweave(defaultGateway); + + let totalBalance = BigNumber("0"); + + await Promise.all( + wallets.map(async ({ address }) => { + try { + const balance = arweave.ar.winstonToAr( + await arweave.wallets.getBalance(address) + ); + totalBalance = totalBalance.plus(balance); + } catch (e) { + console.log("invalid", e); + } + }) + ); + + try { + await trackDirect(EventType.BALANCE, { + totalBalance: totalBalance.toFixed() + }); + + const timer = setToStartOfNextMonth(new Date()); + + browser.alarms.create("track-balance", { + when: timer.getTime() + }); + } catch (err) { + console.log("err tracking", err); + } +} diff --git a/src/api/background/handlers/browser/install/install.handler.ts b/src/api/background/handlers/browser/install/install.handler.ts new file mode 100644 index 000000000..c7a39a879 --- /dev/null +++ b/src/api/background/handlers/browser/install/install.handler.ts @@ -0,0 +1,41 @@ +import { scheduleGatewayUpdate } from "~gateways/cache"; +import browser, { type Runtime } from "webextension-polyfill"; +import { loadTokens } from "~tokens/token"; +import { initializeARBalanceMonitor } from "~utils/analytics"; +import { updateAoToken } from "~utils/ao_import"; +import { handleGatewayUpdateAlarm } from "~api/background/handlers/alarms/gateway-update/gateway-update-alarm.handler"; +import { openOrSelectWelcomePage } from "~wallets"; + +/** + * On extension installed event handler + */ +export async function handleInstall(details: Runtime.OnInstalledDetailsType) { + // only run on install + if (details.reason === "install") { + openOrSelectWelcomePage(true); + } + + // init monthly AR + await initializeARBalanceMonitor(); + + // initialize alarm to fetch notifications + browser.alarms.create("notifications", { periodInMinutes: 1 }); + + // reset notifications + // await ExtensionStorage.set("show_announcement", true); + + // initialize alarm to update tokens once a week + browser.alarms.create("update_ao_tokens", { + periodInMinutes: 10080 + }); + + // initialize tokens in wallet + await loadTokens(); + + // update old ao token to new ao token + await updateAoToken(); + + // wayfinder + await scheduleGatewayUpdate(); + await handleGatewayUpdateAlarm(); +} diff --git a/src/api/background/handlers/browser/printer/get-capabilities/get-capabilities.handler.ts b/src/api/background/handlers/browser/printer/get-capabilities/get-capabilities.handler.ts new file mode 100644 index 000000000..0c2fc890b --- /dev/null +++ b/src/api/background/handlers/browser/printer/get-capabilities/get-capabilities.handler.ts @@ -0,0 +1,75 @@ +import { ARCONNECT_PRINTER_ID } from "~api/background/handlers/browser/printer/printer.constants"; + +/** + * Printer capabilities request callback type + */ +type PrinterInfoCallback = ( + capabilities: chrome.printerProvider.PrinterCapabilities +) => void; + +/** + * Tells Chrome about the virtual printer's + * capabilities in CDD format + */ +export function handleGetCapabilities( + printerId: string, + callback: PrinterInfoCallback +) { + // only return capabilities for the ArConnect printer + if (printerId !== ARCONNECT_PRINTER_ID) return; + + // mimic a regular printer's capabilities + callback({ + capabilities: { + version: "1.0", + printer: { + supported_content_type: [ + { content_type: "application/pdf" }, + { content_type: "image/pwg-raster" } + ], + color: { + option: [ + { type: "STANDARD_COLOR", is_default: true }, + { type: "STANDARD_MONOCHROME" } + ] + }, + copies: { + default_copies: 1, + max_copies: 100 + }, + media_size: { + option: [ + { + name: "ISO_A4", + width_microns: 210000, + height_microns: 297000, + is_default: true + }, + { + name: "NA_LETTER", + width_microns: 215900, + height_microns: 279400 + } + ] + }, + page_orientation: { + option: [ + { + type: "PORTRAIT", + is_default: true + }, + { type: "LANDSCAPE" }, + { type: "AUTO" } + ] + }, + duplex: { + option: [ + { type: "NO_DUPLEX", is_default: true }, + { type: "LONG_EDGE" }, + { type: "SHORT_EDGE" } + ] + } + } + } + }); +} diff --git a/src/api/background/handlers/browser/printer/get-printers/get-printers.handler.ts b/src/api/background/handlers/browser/printer/get-printers/get-printers.handler.ts new file mode 100644 index 000000000..6e7906db6 --- /dev/null +++ b/src/api/background/handlers/browser/printer/get-printers/get-printers.handler.ts @@ -0,0 +1,22 @@ +import { ARCONNECT_PRINTER_ID } from "~api/background/handlers/browser/printer/printer.constants"; + +/** + * Printer info request callback type + */ +type PrinterInfoCallback = (p: chrome.printerProvider.PrinterInfo[]) => void; + +/** + * Returns a list of "virtual" printers, + * in our case "Print/Publish to Arweave" + */ +export function handleGetPrinters(callback: PrinterInfoCallback) { + callback([ + { + id: ARCONNECT_PRINTER_ID, + // TODO: Add to i18n: + name: "Print to Arweave", + description: + "Publish the content you want to print on Arweave, permanently." + } + ]); +} diff --git a/src/lib/printer.ts b/src/api/background/handlers/browser/printer/print/print.handler.ts similarity index 64% rename from src/lib/printer.ts rename to src/api/background/handlers/browser/printer/print/print.handler.ts index 330fb0401..34c0577ae 100644 --- a/src/lib/printer.ts +++ b/src/api/background/handlers/browser/printer/print/print.handler.ts @@ -1,3 +1,4 @@ +import { ARCONNECT_PRINTER_ID } from "~api/background/handlers/browser/printer/printer.constants"; import { uploadDataToTurbo } from "~api/modules/dispatch/uploader"; import { getActiveKeyfile, type DecryptedWallet } from "~wallets"; import { freeDecryptedWallet } from "~wallets/encryption"; @@ -8,104 +9,17 @@ import browser from "webextension-polyfill"; import Arweave from "arweave"; import { signAuth } from "~api/modules/sign/sign_auth"; import { getActiveTab } from "~applications"; -import { sleep } from "~utils/sleep"; - -const ARCONNECT_PRINTER_ID = "arconnect-permaweb-printer"; - -/** - * Tells Chrome about the virtual printer's - * capabilities in CDD format - */ -export function getCapabilities( - printerId: string, - callback: PrinterCapabilitiesCallback -) { - // only return capabilities for the ArConnect printer - if (printerId !== ARCONNECT_PRINTER_ID) return; - - // mimic a regular printer's capabilities - callback({ - version: "1.0", - printer: { - supported_content_type: [ - { content_type: "application/pdf" }, - { content_type: "image/pwg-raster" } - ], - color: { - option: [ - { type: "STANDARD_COLOR", is_default: true }, - { type: "STANDARD_MONOCHROME" } - ] - }, - copies: { - default_copies: 1, - max_copies: 100 - }, - media_size: { - option: [ - { - name: "ISO_A4", - width_microns: 210000, - height_microns: 297000, - is_default: true - }, - { - name: "NA_LETTER", - width_microns: 215900, - height_microns: 279400 - } - ] - }, - page_orientation: { - option: [ - { - type: "PORTRAIT", - is_default: true - }, - { type: "LANDSCAPE" }, - { type: "AUTO" } - ] - }, - duplex: { - option: [ - { type: "NO_DUPLEX", is_default: true }, - { type: "LONG_EDGE" }, - { type: "SHORT_EDGE" } - ] - } - } - }); -} - -/** - * Printer capabilities request callback type - */ -type PrinterCapabilitiesCallback = (p: unknown) => void; +import { sleep } from "~utils/promises/sleep"; /** - * Returns a list of "virtual" printers, - * in our case "Print/Publish to Arweave" - */ -export function getPrinters(callback: PrinterInfoCallback) { - callback([ - { - id: ARCONNECT_PRINTER_ID, - name: "Print to Arweave", - description: - "Publish the content you want to print on Arweave, permanently." - } - ]); -} - -/** - * Printer info request callback type + * Print request (result) callback */ -type PrinterInfoCallback = (p: chrome.printerProvider.PrinterInfo[]) => void; +type PrintCallback = (result: string) => void; /** * Handles the request from the user to print the page to Arweave */ -export async function handlePrintRequest( +export async function handlePrint( printJob: chrome.printerProvider.PrintJob, resultCallback: PrintCallback ) { @@ -158,7 +72,10 @@ export async function handlePrintRequest( const activeTab = await getActiveTab(); await signAuth( - activeTab.url, + { + tabID: activeTab.id, + url: activeTab.url + }, // @ts-expect-error { ...dataEntry.toJSON(), @@ -220,8 +137,3 @@ export async function handlePrintRequest( if (decryptedWallet?.type == "local") freeDecryptedWallet(decryptedWallet.keyfile); } - -/** - * Print request (result) callback - */ -type PrintCallback = (result: string) => void; diff --git a/src/api/background/handlers/browser/printer/printer.constants.ts b/src/api/background/handlers/browser/printer/printer.constants.ts new file mode 100644 index 000000000..831c7fbac --- /dev/null +++ b/src/api/background/handlers/browser/printer/printer.constants.ts @@ -0,0 +1 @@ +export const ARCONNECT_PRINTER_ID = "arconnect-permaweb-printer"; diff --git a/src/api/background/handlers/browser/protocol/protocol.handler.ts b/src/api/background/handlers/browser/protocol/protocol.handler.ts new file mode 100644 index 000000000..2b7b886cf --- /dev/null +++ b/src/api/background/handlers/browser/protocol/protocol.handler.ts @@ -0,0 +1,33 @@ +import { getRedirectURL } from "~gateways/ar_protocol"; +import { findGateway } from "~gateways/wayfinder"; +import browser, { type WebNavigation } from "webextension-polyfill"; + +/** + * Handle custom ar:// protocol, using the + * browser.webNavigation.onBeforeNavigate API. + * + * This is based on the issue in ipfs/ipfs-companion: + * https://github.com/ipfs/ipfs-companion/issues/164#issuecomment-328374052 + * + * Thank you ar.io for the updated method: + * https://github.com/ar-io/wayfinder/blob/main/background.js#L13 + */ +export async function handleProtocol( + details: WebNavigation.OnBeforeNavigateDetailsType +) { + const gateway = await findGateway({ + arns: true, + ensureStake: true + }); + + // parse redirect url + const redirectUrl = getRedirectURL(new URL(details.url), gateway); + + // don't do anything if it is not a protocol call + if (!redirectUrl) return; + + // update tab + browser.tabs.update(details.tabId, { + url: redirectUrl + }); +} diff --git a/src/api/background/handlers/browser/tabs/tabs.handler.ts b/src/api/background/handlers/browser/tabs/tabs.handler.ts new file mode 100644 index 000000000..ffff25174 --- /dev/null +++ b/src/api/background/handlers/browser/tabs/tabs.handler.ts @@ -0,0 +1,89 @@ +import Application from "~applications/application"; +import { getTab } from "~applications/tab"; +import { + getCachedAuthPopupWindowTabID, + resetKeepAlive, + resetPopupTabID +} from "~utils/auth/auth.utils"; +import { createContextMenus } from "~utils/context_menus"; +import { getAppURL } from "~utils/format"; +import { updateIcon } from "~utils/icon"; +import { isomorphicSendMessage } from "~utils/messaging/messaging.utils"; +import browser from "webextension-polyfill"; + +/** + * Handle tab updates (icon change, context menus, etc.) + * + * @param tabId ID of the tab to get. + */ +export async function handleTabUpdate( + tabID: number, + changeInfo?: browser.Tabs.OnUpdatedChangeInfoType +) { + const popupTabID = getCachedAuthPopupWindowTabID(); + + if (popupTabID !== -1 && changeInfo?.status === "loading") { + isomorphicSendMessage({ + messageId: "auth_tab_reloaded", + tabId: popupTabID, + data: tabID + }); + } + + // construct app + const tab = await getTab(tabID); + + // if we cannot parse the tab URL, the extension is not connected + if (!tab?.url) { + updateIcon(false); + createContextMenus(false); + return; + } + + const app = new Application(getAppURL(tab.url)); + + // change icon to "connected" status if + // the site is connected and add the + // context menus + const connected = await app.isConnected(); + + updateIcon(connected); + createContextMenus(connected); +} + +/** + * Notifies the auth popup about closed tab for it to abort AuthRequests coming from those tabs. + * + * @param tabId ID of the closed tab. + */ +export async function handleTabClosed(closedTabID: number) { + const popupTabID = getCachedAuthPopupWindowTabID(); + + // If there's no popup, then we do nothing: + if (popupTabID === -1) return; + + if (closedTabID === popupTabID) { + // If the closed tab was the popup, we reset its ID and the keep-alive alarm: + resetPopupTabID(); + resetKeepAlive(); + + // Make sure there were no duplicate auth popups and, if so, close them too: + try { + const url = browser.runtime.getURL("tabs/auth.html"); + const authPopups = await browser.tabs.query({ url }); + + browser.tabs.remove(authPopups.map((authPopup) => authPopup.id)); + } catch (err) { + console.warn("Error trying to close other auth popups:", err); + } + + return; + } + + // If some other tab was closed and there's a popup, notify the popup in case it has AuthRequest from the closed tab: + isomorphicSendMessage({ + messageId: "auth_tab_closed", + tabId: popupTabID, + data: closedTabID + }); +} diff --git a/src/api/background/handlers/browser/window-close/window-close.handler.ts b/src/api/background/handlers/browser/window-close/window-close.handler.ts new file mode 100644 index 000000000..a9b2d1a5f --- /dev/null +++ b/src/api/background/handlers/browser/window-close/window-close.handler.ts @@ -0,0 +1,20 @@ +import { removeDecryptionKey } from "~wallets/auth"; +import browser from "webextension-polyfill"; + +/** + * Listener for browser close. + * On browser closed, we remove the + * decryptionKey. + */ +export async function handleWindowClose() { + const windows = await browser.windows.getAll(); + + // TODO: Maybe we should be counting connected apps instead? + // return if there are still windows open + if (windows.length > 0) { + return; + } + + // remove the decryption key + await removeDecryptionKey(); +} diff --git a/src/api/index.ts b/src/api/background/handlers/message/api-call-message/api-call-message.handler.ts similarity index 62% rename from src/api/index.ts rename to src/api/background/handlers/message/api-call-message/api-call-message.handler.ts index 05d2fe800..5e3a5b75e 100644 --- a/src/api/index.ts +++ b/src/api/background/handlers/message/api-call-message/api-call-message.handler.ts @@ -1,16 +1,18 @@ import { isExactly, isNotUndefined, isString } from "typed-assert"; import type { OnMessageCallback } from "@arconnect/webext-bridge"; -import { type Chunk, handleChunk } from "./modules/sign/chunks"; -import { isApiCall, isChunk } from "~utils/assertions"; +import { isApiCall } from "~utils/assertions"; import Application from "~applications/application"; import type { ApiCall, ApiResponse } from "shim"; import browser from "webextension-polyfill"; import { getTab } from "~applications/tab"; import { pushEvent } from "~utils/events"; import { getAppURL } from "~utils/format"; -import modules from "./background"; +import { + backgroundModules, + type ModuleAppData +} from "~api/background/background-modules"; -export const handleApiCalls: OnMessageCallback< +export const handleApiCallMessage: OnMessageCallback< // @ts-expect-error ApiCall, ApiResponse @@ -45,7 +47,9 @@ export const handleApiCalls: OnMessageCallback< // find module to execute const functionName = data.type.replace("api_", ""); - const mod = modules.find((mod) => mod.functionName === functionName); + const mod = backgroundModules.find( + (mod) => mod.functionName === functionName + ); // if we cannot find the module, we return with an error isNotUndefined(mod, `API function "${functionName}" not found`); @@ -97,9 +101,10 @@ export const handleApiCalls: OnMessageCallback< // handle function const functionResult = await mod.function( { - appURL: app.url, + tabID: tab.id, + url: app.url, favicon: tab.favIconUrl - }, + } satisfies ModuleAppData, ...(data.data.params || []) ); @@ -119,65 +124,3 @@ export const handleApiCalls: OnMessageCallback< }; } }; - -export const handleChunkCalls: OnMessageCallback< - // @ts-expect-error - ApiCall, - ApiResponse -> = async ({ data, sender }) => { - // construct base message to extend and return - const baseMessage: ApiResponse = { - type: "chunk_result", - callID: data.callID - }; - - try { - // validate message - isExactly( - sender.context, - "content-script", - "Chunk calls are only accepted from the injected-script -> content-script" - ); - isChunk(data.data); - - // grab the tab where the chunk came - const tab = await getTab(sender.tabId); - - // if the tab is not found, reject the call - isString(tab?.url, "Call coming from invalid tab"); - - // raw url where the chunk originates from - let url = tab.url; - - // if the frame ID is defined, the API - // request is not coming from the main tab - // but from an iframe in the tab. - // we need to treat the iframe as a separate - // application to ensure the user does not - // mistake it for the actual app - if (typeof sender.frameId !== "undefined") { - const frame = await browser.webNavigation.getFrame({ - frameId: sender.frameId, - tabId: sender.tabId - }); - - // update url value with the url belonging to the frame - if (frame.url) url = frame.url; - } - - // call the chunk handler - const index = handleChunk(data.data, getAppURL(url)); - - return { - ...baseMessage, - data: index - }; - } catch (e) { - // return error - return { - ...baseMessage, - error: true, - data: e?.message || e - }; - } -}; diff --git a/src/api/background/handlers/message/chunk-message/chunk-message.handler.ts b/src/api/background/handlers/message/chunk-message/chunk-message.handler.ts new file mode 100644 index 000000000..5bd19ca60 --- /dev/null +++ b/src/api/background/handlers/message/chunk-message/chunk-message.handler.ts @@ -0,0 +1,70 @@ +import { isExactly, isString } from "typed-assert"; +import type { OnMessageCallback } from "@arconnect/webext-bridge"; +import { type Chunk, handleChunk } from "../../../../modules/sign/chunks"; +import { isChunk } from "~utils/assertions"; +import type { ApiCall, ApiResponse } from "shim"; +import browser from "webextension-polyfill"; +import { getTab } from "~applications/tab"; +import { getAppURL } from "~utils/format"; + +export const handleChunkMessage: OnMessageCallback< + // @ts-expect-error + ApiCall, + ApiResponse +> = async ({ data, sender }) => { + // construct base message to extend and return + const baseMessage: ApiResponse = { + type: "chunk_result", + callID: data.callID + }; + + try { + // validate message + isExactly( + sender.context, + "content-script", + "Chunk calls are only accepted from the injected-script -> content-script" + ); + isChunk(data.data); + + // grab the tab where the chunk came + const tab = await getTab(sender.tabId); + + // if the tab is not found, reject the call + isString(tab?.url, "Call coming from invalid tab"); + + // raw url where the chunk originates from + let url = tab.url; + + // if the frame ID is defined, the API + // request is not coming from the main tab + // but from an iframe in the tab. + // we need to treat the iframe as a separate + // application to ensure the user does not + // mistake it for the actual app + if (typeof sender.frameId !== "undefined") { + const frame = await browser.webNavigation.getFrame({ + frameId: sender.frameId, + tabId: sender.tabId + }); + + // update url value with the url belonging to the frame + if (frame.url) url = frame.url; + } + + // call the chunk handler + const index = handleChunk(data.data, getAppURL(url)); + + return { + ...baseMessage, + data: index + }; + } catch (e) { + // return error + return { + ...baseMessage, + error: true, + data: e?.message || e + }; + } +}; diff --git a/src/api/background/handlers/storage/active-address-change/active-address-change.handler.ts b/src/api/background/handlers/storage/active-address-change/active-address-change.handler.ts new file mode 100644 index 000000000..64d740aa2 --- /dev/null +++ b/src/api/background/handlers/storage/active-address-change/active-address-change.handler.ts @@ -0,0 +1,62 @@ +import { sendMessage } from "@arconnect/webext-bridge"; +import type { StorageChange } from "~utils/runtime"; +import Application from "~applications/application"; +import { forEachTab } from "~applications/tab"; +import { getAppURL } from "~utils/format"; +import { isomorphicSendMessage } from "~utils/messaging/messaging.utils"; +import { getCachedAuthPopupWindowTabID } from "~utils/auth/auth.utils"; + +/** + * Active address change event listener. + * Sends a message to fire the "walletSwitch" + * event in the tab. + */ +export async function handleActiveAddressChange({ + oldValue, + newValue: newAddress +}: StorageChange) { + if (!newAddress || oldValue === newAddress) return; + + // go through all tabs and check if they + // have the permissions to receive the + // wallet switch event + await forEachTab(async (tab) => { + const app = new Application(getAppURL(tab.url)); + + // check required permissions + const permissionCheck = await app.hasPermissions([ + "ACCESS_ALL_ADDRESSES", + "ACCESS_ADDRESS" + ]); + + // app not connected + if (permissionCheck.has.length === 0) return; + + // trigger emitter + await sendMessage( + "event", + { + name: "activeAddress", + value: permissionCheck.result ? newAddress : null + }, + `content-script@${tab.id}` + ); + + const popupTabID = getCachedAuthPopupWindowTabID(); + + if (popupTabID) { + isomorphicSendMessage({ + messageId: "auth_active_wallet_change", + tabId: popupTabID, + data: tab.id + }); + } + + // trigger event via message + await sendMessage( + "switch_wallet_event", + permissionCheck ? newAddress : null, + `content-script@${tab.id}` + ); + }); +} diff --git a/src/applications/events.ts b/src/api/background/handlers/storage/app-config-change/app-config-change.handler.ts similarity index 87% rename from src/applications/events.ts rename to src/api/background/handlers/storage/app-config-change/app-config-change.handler.ts index b46986f9a..ca7e04f56 100644 --- a/src/applications/events.ts +++ b/src/api/background/handlers/storage/app-config-change/app-config-change.handler.ts @@ -1,14 +1,15 @@ -import Application, { type InitAppParams, PREFIX } from "./application"; import { sendMessage } from "@arconnect/webext-bridge"; -import { getMissingPermissions } from "./permissions"; import type { StorageChange } from "~utils/runtime"; import { getStoredApps } from "~applications"; -import { compareGateways } from "../gateways/utils"; import { getAppURL } from "~utils/format"; -import { forEachTab } from "./tab"; import type { Event } from "shim"; +import { getMissingPermissions } from "~applications/permissions"; +import { forEachTab } from "~applications/tab"; +import { compareGateways } from "~gateways/utils"; +import type { InitAppParams } from "~applications/application"; +import Application, { PREFIX } from "~applications/application"; -export async function appConfigChangeListener( +export async function handleAppConfigChange( changes: Record>, areaName: string ) { @@ -47,7 +48,7 @@ export async function appConfigChangeListener( storedNewValue as unknown as string ) as InitAppParams; - // check if permission event emiting is needed + // check if permission event emitting is needed // get missing permissions const missingPermissions = getMissingPermissions( oldValue?.permissions || [], diff --git a/src/api/background/handlers/storage/apps-change/app-change.handler.ts b/src/api/background/handlers/storage/apps-change/app-change.handler.ts new file mode 100644 index 000000000..c5f8a8926 --- /dev/null +++ b/src/api/background/handlers/storage/apps-change/app-change.handler.ts @@ -0,0 +1,93 @@ +import { createContextMenus } from "~utils/context_menus"; +import { sendMessage } from "@arconnect/webext-bridge"; +import type { StorageChange } from "~utils/runtime"; +import { getAppURL } from "~utils/format"; +import { updateIcon } from "~utils/icon"; +import { forEachTab } from "~applications/tab"; +import { getActiveTab } from "~applications"; +import Application from "~applications/application"; +import { isomorphicSendMessage } from "~utils/messaging/messaging.utils"; +import { getCachedAuthPopupWindowTabID } from "~utils/auth/auth.utils"; + +/** + * App disconnected listener. Sends a message + * to trigger the disconnected event. + */ +export async function handleAppsChange({ + oldValue, + newValue +}: StorageChange) { + // message to send the event + const triggerEvent = (tabID: number, type: "connect" | "disconnect") => + sendMessage( + "event", + { + name: type, + value: null + }, + `content-script@${tabID}` + ); + + // trigger events + forEachTab(async (tab) => { + // get app url + const appURL = getAppURL(tab.url); + + // if the new value is undefined + // and the old value was defined + // we need to emit the disconnect + // event for all tabs that were + // connected + if (!newValue && !!oldValue) { + if (!oldValue.includes(appURL)) return; + + const popupTabID = getCachedAuthPopupWindowTabID(); + + if (popupTabID) { + isomorphicSendMessage({ + messageId: "auth_app_disconnected", + tabId: popupTabID, + data: tab.id + }); + } + + return await triggerEvent(tab.id, "disconnect"); + } else if (!newValue) { + // if the new value is undefined + // and the old value was also + // undefined, we just return + return; + } + + const oldAppsList = oldValue || []; + + // if the new value includes the app url + // and the old value does not, than the + // app has just been connected + // if the reverse is true, than the app + // has just been disconnected + if (newValue.includes(appURL) && !oldAppsList.includes(appURL)) { + await triggerEvent(tab.id, "connect"); + } else if (!newValue.includes(appURL) && oldAppsList.includes(appURL)) { + const popupTabID = getCachedAuthPopupWindowTabID(); + + if (popupTabID) { + isomorphicSendMessage({ + messageId: "auth_app_disconnected", + tabId: popupTabID, + data: tab.id + }); + } + + await triggerEvent(tab.id, "disconnect"); + } + }); + + // update icon and context menus + const activeTab = await getActiveTab(); + const app = new Application(getAppURL(activeTab.url)); + const connected = await app.isConnected(); + + await updateIcon(connected); + await createContextMenus(connected); +} diff --git a/src/wallets/event.ts b/src/api/background/handlers/storage/wallet-change/wallet-change.handler.ts similarity index 64% rename from src/wallets/event.ts rename to src/api/background/handlers/storage/wallet-change/wallet-change.handler.ts index a1ae279f4..b1129d626 100644 --- a/src/wallets/event.ts +++ b/src/api/background/handlers/storage/wallet-change/wallet-change.handler.ts @@ -6,57 +6,12 @@ import { forEachTab } from "~applications/tab"; import { getAppURL } from "~utils/format"; import browser from "webextension-polyfill"; -/** - * Active address change event listener. - * Sends a message to fire the "walletSwitch" - * event in the tab. - */ -export async function addressChangeListener({ - oldValue, - newValue: newAddress -}: StorageChange) { - if (!newAddress || oldValue === newAddress) return; - - // go through all tabs and check if they - // have the permissions to receive the - // wallet switch event - await forEachTab(async (tab) => { - const app = new Application(getAppURL(tab.url)); - - // check required permissions - const permissionCheck = await app.hasPermissions([ - "ACCESS_ALL_ADDRESSES", - "ACCESS_ADDRESS" - ]); - - // app not connected - if (permissionCheck.has.length === 0) return; - - // trigger emiter - await sendMessage( - "event", - { - name: "activeAddress", - value: permissionCheck.result ? newAddress : null - }, - `content-script@${tab.id}` - ); - - // trigger event via message - await sendMessage( - "switch_wallet_event", - permissionCheck ? newAddress : null, - `content-script@${tab.id}` - ); - }); -} - /** * Added wallets change listener. * Fixup active address in case the current * active address' wallet has been removed. */ -export async function walletsChangeListener({ +export async function handleWalletsChange({ newValue, oldValue }: StorageChange) { diff --git a/src/api/foreground.ts b/src/api/foreground.ts deleted file mode 100644 index bd079a7b7..000000000 --- a/src/api/foreground.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { Module, ModuleFunction } from "./module"; - -// import modules -import permissionsModule from "./modules/permissions"; -import permissions from "./modules/permissions/permissions.foreground"; -import activeAddressModule from "./modules/active_address"; -import activeAddress from "./modules/active_address/active_address.foreground"; -import allAddressesModule from "./modules/all_addresses"; -import allAddresses from "./modules/all_addresses/all_addresses.foreground"; -import publicKeyModule from "./modules/public_key"; -import publicKey from "./modules/public_key/public_key.foreground"; -import walletNamesModule from "./modules/wallet_names"; -import walletNames from "./modules/wallet_names/wallet_names.foreground"; -import arweaveConfigModule from "./modules/arweave_config"; -import arweaveConfig from "./modules/arweave_config/arweave_config.foreground"; -import disconnectModule from "./modules/disconnect"; -import disconnect, { - finalizer as disconnectFinalizer -} from "./modules/disconnect/disconnect.foreground"; -import addTokenModule from "./modules/add_token"; -import addToken from "./modules/add_token/add_token.foreground"; -import isTokenAddedModule from "./modules/is_token_added"; -import isTokenAdded from "./modules/is_token_added/is_token_added.foreground"; -import connectModule from "./modules/connect"; -import connect from "./modules/connect/connect.foreground"; -import signModule from "./modules/sign"; -import sign, { - finalizer as signFinalizer -} from "./modules/sign/sign.foreground"; -import dispatchModule from "./modules/dispatch"; -import dispatch, { - finalizer as dispatchFinalizer -} from "./modules/dispatch/dispatch.foreground"; -import encryptModule from "./modules/encrypt"; -import encrypt, { - finalizer as encryptFinalizer -} from "./modules/encrypt/encrypt.foreground"; -import decryptModule from "./modules/decrypt"; -import decrypt, { - finalizer as decryptFinalizer -} from "./modules/decrypt/decrypt.foreground"; -import signatureModule from "./modules/signature"; -import signature, { - finalizer as signatureFinalizer -} from "./modules/signature/signature.foreground"; -import signMessageModule from "./modules/sign_message"; -import signMessage, { - finalizer as signMessageFinalizer -} from "./modules/sign_message/sign_message.foreground"; -import subscriptionModule from "./modules/subscription"; -import subscription from "./modules/subscription/subscription.foreground"; -import privateHashModule from "./modules/private_hash"; -import privateHash, { - finalizer as privateHashFinalizer -} from "./modules/private_hash/private_hash.foreground"; -import verifyMessageModule from "./modules/verify_message"; -import verifyMessage from "./modules/verify_message/verify_message.foreground"; -import batchSignDataItemModule from "./modules/batch_sign_data_item"; -import batchSignDataItem, { - finalizer as batchSignDataItemFinalizer -} from "./modules/batch_sign_data_item/batch_sign_data_item.foreground"; -import signDataItemModule from "./modules/sign_data_item"; -import signDataItem, { - finalizer as signDataItemFinalizer -} from "./modules/sign_data_item/sign_data_item.foreground"; -import userTokensModule from "./modules/user_tokens"; -import userTokens from "./modules/user_tokens/user_tokens.foreground"; -import tokenBalanceModule from "./modules/token_balance"; -import tokenBalance from "./modules/token_balance/token_balance.foreground"; - -/** Foreground modules */ -const modules: ForegroundModule[] = [ - { ...permissionsModule, function: permissions }, - { ...activeAddressModule, function: activeAddress }, - { ...allAddressesModule, function: allAddresses }, - { ...publicKeyModule, function: publicKey }, - { ...walletNamesModule, function: walletNames }, - { ...arweaveConfigModule, function: arweaveConfig }, - { ...disconnectModule, function: disconnect, finalizer: disconnectFinalizer }, - { ...connectModule, function: connect }, - { ...signModule, function: sign, finalizer: signFinalizer }, - { ...dispatchModule, function: dispatch, finalizer: dispatchFinalizer }, - { ...encryptModule, function: encrypt, finalizer: encryptFinalizer }, - { ...decryptModule, function: decrypt, finalizer: decryptFinalizer }, - { ...signatureModule, function: signature, finalizer: signatureFinalizer }, - { ...addTokenModule, function: addToken }, - { ...isTokenAddedModule, function: isTokenAdded }, - { - ...signMessageModule, - function: signMessage, - finalizer: signMessageFinalizer - }, - { - ...privateHashModule, - function: privateHash, - finalizer: privateHashFinalizer - }, - { ...verifyMessageModule, function: verifyMessage }, - { - ...signDataItemModule, - function: signDataItem, - finalizer: signDataItemFinalizer - }, - { ...subscriptionModule, function: subscription }, - { ...userTokensModule, function: userTokens }, - { ...tokenBalanceModule, function: tokenBalance }, - { - ...batchSignDataItemModule, - function: batchSignDataItem, - finalizer: batchSignDataItemFinalizer - } -]; - -export default modules; - -/** Extended module interface */ -interface ForegroundModule extends Module { - /** - * A function that runs after results were - * returned from the background script. - * This is optional and will be ignored if not set. - */ - finalizer?: ModuleFunction | TransformFinalizer; -} - -/** - * @param result The result from the background script - * @param params The params the background script received - * @param originalParams The params the injected function was called with - */ -export type TransformFinalizer< - ResultType, - ParamsType = any, - OriginalParamsType = any -> = ( - result: ResultType, - params: ParamsType, - originalParams: OriginalParamsType -) => any; diff --git a/src/api/foreground/foreground-modules.ts b/src/api/foreground/foreground-modules.ts new file mode 100644 index 000000000..64b5fef78 --- /dev/null +++ b/src/api/foreground/foreground-modules.ts @@ -0,0 +1,137 @@ +import type { Module, ModuleFunction } from "../module"; + +// import modules +import permissionsModule from "../modules/permissions"; +import permissions from "../modules/permissions/permissions.foreground"; +import activeAddressModule from "../modules/active_address"; +import activeAddress from "../modules/active_address/active_address.foreground"; +import allAddressesModule from "../modules/all_addresses"; +import allAddresses from "../modules/all_addresses/all_addresses.foreground"; +import publicKeyModule from "../modules/public_key"; +import publicKey from "../modules/public_key/public_key.foreground"; +import walletNamesModule from "../modules/wallet_names"; +import walletNames from "../modules/wallet_names/wallet_names.foreground"; +import arweaveConfigModule from "../modules/arweave_config"; +import arweaveConfig from "../modules/arweave_config/arweave_config.foreground"; +import disconnectModule from "../modules/disconnect"; +import disconnect, { + finalizer as disconnectFinalizer +} from "../modules/disconnect/disconnect.foreground"; +import addTokenModule from "../modules/add_token"; +import addToken from "../modules/add_token/add_token.foreground"; +import isTokenAddedModule from "../modules/is_token_added"; +import isTokenAdded from "../modules/is_token_added/is_token_added.foreground"; +import connectModule from "../modules/connect"; +import connect from "../modules/connect/connect.foreground"; +import signModule from "../modules/sign"; +import sign, { + finalizer as signFinalizer +} from "../modules/sign/sign.foreground"; +import dispatchModule from "../modules/dispatch"; +import dispatch, { + finalizer as dispatchFinalizer +} from "../modules/dispatch/dispatch.foreground"; +import encryptModule from "../modules/encrypt"; +import encrypt, { + finalizer as encryptFinalizer +} from "../modules/encrypt/encrypt.foreground"; +import decryptModule from "../modules/decrypt"; +import decrypt, { + finalizer as decryptFinalizer +} from "../modules/decrypt/decrypt.foreground"; +import signatureModule from "../modules/signature"; +import signature, { + finalizer as signatureFinalizer +} from "../modules/signature/signature.foreground"; +import signMessageModule from "../modules/sign_message"; +import signMessage, { + finalizer as signMessageFinalizer +} from "../modules/sign_message/sign_message.foreground"; +import subscriptionModule from "../modules/subscription"; +import subscription from "../modules/subscription/subscription.foreground"; +import privateHashModule from "../modules/private_hash"; +import privateHash, { + finalizer as privateHashFinalizer +} from "../modules/private_hash/private_hash.foreground"; +import verifyMessageModule from "../modules/verify_message"; +import verifyMessage from "../modules/verify_message/verify_message.foreground"; +import batchSignDataItemModule from "../modules/batch_sign_data_item"; +import batchSignDataItem, { + finalizer as batchSignDataItemFinalizer +} from "../modules/batch_sign_data_item/batch_sign_data_item.foreground"; +import signDataItemModule from "../modules/sign_data_item"; +import signDataItem, { + finalizer as signDataItemFinalizer +} from "../modules/sign_data_item/sign_data_item.foreground"; +import userTokensModule from "../modules/user_tokens"; +import userTokens from "../modules/user_tokens/user_tokens.foreground"; +import tokenBalanceModule from "../modules/token_balance"; +import tokenBalance from "../modules/token_balance/token_balance.foreground"; + +/** + * @param result The result from the background script + * @param params The params the background script received + * @param originalParams The params the injected function was called with + */ +export type TransformFinalizer< + ResultType, + ParamsType = any, + OriginalParamsType = any +> = ( + result: ResultType, + params: ParamsType, + originalParams: OriginalParamsType +) => any; + +/** Extended module interface */ +export interface ForegroundModule extends Module { + /** + * A function that runs after results were + * returned from the background script. + * This is optional and will be ignored if not set. + */ + finalizer?: ModuleFunction | TransformFinalizer; +} + +/** Foreground modules */ +export const foregroundModules: ForegroundModule[] = [ + { ...permissionsModule, function: permissions }, + { ...activeAddressModule, function: activeAddress }, + { ...allAddressesModule, function: allAddresses }, + { ...publicKeyModule, function: publicKey }, + { ...walletNamesModule, function: walletNames }, + { ...arweaveConfigModule, function: arweaveConfig }, + { ...disconnectModule, function: disconnect, finalizer: disconnectFinalizer }, + { ...connectModule, function: connect }, + { ...signModule, function: sign, finalizer: signFinalizer }, + { ...dispatchModule, function: dispatch, finalizer: dispatchFinalizer }, + { ...encryptModule, function: encrypt, finalizer: encryptFinalizer }, + { ...decryptModule, function: decrypt, finalizer: decryptFinalizer }, + { ...signatureModule, function: signature, finalizer: signatureFinalizer }, + { ...addTokenModule, function: addToken }, + { ...isTokenAddedModule, function: isTokenAdded }, + { + ...signMessageModule, + function: signMessage, + finalizer: signMessageFinalizer + }, + { + ...privateHashModule, + function: privateHash, + finalizer: privateHashFinalizer + }, + { ...verifyMessageModule, function: verifyMessage }, + { + ...signDataItemModule, + function: signDataItem, + finalizer: signDataItemFinalizer + }, + { ...subscriptionModule, function: subscription }, + { ...userTokensModule, function: userTokens }, + { ...tokenBalanceModule, function: tokenBalance }, + { + ...batchSignDataItemModule, + function: batchSignDataItem, + finalizer: batchSignDataItemFinalizer + } +]; diff --git a/src/contents/ar_protocol.ts b/src/api/foreground/foreground-setup-ar-protocol-links.ts similarity index 82% rename from src/contents/ar_protocol.ts rename to src/api/foreground/foreground-setup-ar-protocol-links.ts index c5b024807..78c919054 100644 --- a/src/contents/ar_protocol.ts +++ b/src/api/foreground/foreground-setup-ar-protocol-links.ts @@ -1,14 +1,9 @@ import { sendMessage } from "@arconnect/webext-bridge"; -import type { PlasmoCSConfig } from "plasmo"; import { isString } from "typed-assert"; -export const config: PlasmoCSConfig = { - matches: ["file://*/*", "http://*/*", "https://*/*"], - run_at: "document_start", - all_frames: true -}; +// TODO: This is not enough for client-rendered pages (e.g. React apps): -document.addEventListener("DOMContentLoaded", async () => { +export async function replaceArProtocolLinks() { // all elements with the "ar://" protocol const elements = document.querySelectorAll( 'a[href^="ar://"], img[src^="ar://"], iframe[src^="ar://"], ' + @@ -16,6 +11,7 @@ document.addEventListener("DOMContentLoaded", async () => { 'link[href^="ar://"], embed[src^="ar://"], object[data^="ar://"],' + 'script[src^="ar://"]' ); + const fields = { src: ["img", "iframe", "source", "embed", "script"], href: ["a", "link"], @@ -25,6 +21,7 @@ document.addEventListener("DOMContentLoaded", async () => { for (const el of elements) { // ask the background script to return the correct ar:// url try { + // TODO: Replace with postMessage for the embedded wallet: const res = await sendMessage( "ar_protocol", { url: el[fields[el.tagName]] }, @@ -47,4 +44,4 @@ document.addEventListener("DOMContentLoaded", async () => { console.error(`Failed to load ar:// resource: ${el[fields[el.tagName]]}`); } } -}); +} diff --git a/src/api/foreground/foreground-setup-events.ts b/src/api/foreground/foreground-setup-events.ts new file mode 100644 index 000000000..c55a12639 --- /dev/null +++ b/src/api/foreground/foreground-setup-events.ts @@ -0,0 +1,52 @@ +import { onMessage } from "@arconnect/webext-bridge"; + +// Some backend handlers (`src/api/background/handlers/*`) will use `sendMessage(...)` to communicate with the +// `event.ts` content script, which in turn calls `postMessage()`, dispatches events or performs certain actions in the +// content script's context. +// +// In ArConnect Embedded, instead of using `onMessage`, we should listen for messages coming from the iframe itself. +// This also means that the background scripts, which in ArConnect Embedded run directly inside the iframe, need to be +// updated to send messages using `postMessage`. +// +// See https://stackoverflow.com/questions/16266474/javascript-listen-for-postmessage-events-from-specific-iframe + +export function setupEventListeners(iframe?: HTMLIFrameElement) { + // event emitter events + onMessage("event", ({ data, sender }) => { + if (sender.context !== "background") return; + + // send to mitt instance + postMessage({ + type: "arconnect_event", + event: data + }); + }); + + // listen for wallet switches + /** @deprecated */ + onMessage("switch_wallet_event", ({ data, sender }) => { + if (sender.context !== "background") return; + + // dispatch custom event + dispatchEvent( + new CustomEvent("walletSwitch", { + detail: { address: data } + }) + ); + }); + + // copy address in the content script + // (not possible in the background) + onMessage("copy_address", async ({ sender, data: addr }) => { + if (sender.context !== "background") return; + + const input = document.createElement("input"); + + input.value = addr; + + document.body.appendChild(input); + input.select(); + document.execCommand("Copy"); + document.body.removeChild(input); + }); +} diff --git a/src/api/foreground/foreground-setup-wallet-sdk.ts b/src/api/foreground/foreground-setup-wallet-sdk.ts new file mode 100644 index 000000000..c961f31ca --- /dev/null +++ b/src/api/foreground/foreground-setup-wallet-sdk.ts @@ -0,0 +1,138 @@ +import type { ApiCall, ApiResponse, Event } from "shim"; +import type { InjectedEvents } from "~utils/events"; +import { nanoid } from "nanoid"; +import { foregroundModules } from "~api/foreground/foreground-modules"; +import mitt from "mitt"; +import { log, LOG_GROUP } from "~utils/log/log.utils"; +import { version } from "../../../package.json"; + +export function setupWalletSDK(targetWindow: Window = window) { + log(LOG_GROUP.SETUP, "setupWalletSDK()"); + + /** Init events */ + const events = mitt(); + + /** Init wallet API */ + const WalletAPI: Record = { + walletName: "ArConnect", + walletVersion: version, + events + }; + + /** Inject each module */ + for (const mod of foregroundModules) { + /** Handle foreground module and forward the result to the background */ + WalletAPI[mod.functionName] = (...params: any[]) => + new Promise(async (resolve, reject) => { + // execute foreground module + // TODO: Use a default function for those that do not have/need one and see if chunking can be done automatically or if it is needed at all: + const foregroundResult = await mod.function(...params); + + // construct data to send to the background + const callID = nanoid(); + const data: ApiCall & { ext: "arconnect" } = { + type: `api_${mod.functionName}`, + ext: "arconnect", + callID, + data: { + params: foregroundResult || params + } + }; + + // TODO: Replace `postMessage` with `isomorphicSendMessage`, which should be updated to handle + // chunking automatically based on data size, rather than relying on `sendChunk` to be called from + // the foreground scripts manually. + + // Send message to background script (ArConnect Extension) or to the iframe window (ArConnect Embedded): + targetWindow.postMessage(data, window.location.origin); + + // TODO: Note this is replacing the following from `api.content-script.ts`, so the logic to await and get the response is missing with just the + // one-line change above. + // + // const res = await sendMessage( + // data.type === "chunk" ? "chunk" : "api_call", + // data, + // "background" + // ); + // + // window.postMessage(res, window.location.origin); + + // wait for result from background + window.addEventListener("message", callback); + + // TODO: Declare outside (factory) to facilitate testing? + async function callback(e: MessageEvent) { + // TODO: Make sure the response comes from targetWindow. + // See https://stackoverflow.com/questions/16266474/javascript-listen-for-postmessage-events-from-specific-iframe. + + let { data: res } = e; + + // validate return message + if (`${data.type}_result` !== res.type) return; + + // only resolve when the result matching our callID is deleivered + if (data.callID !== res.callID) return; + + window.removeEventListener("message", callback); + + // check for errors + if (res.error) { + return reject(res.data); + } + + // call the finalizer function if it exists + if (mod.finalizer) { + const finalizerResult = await mod.finalizer( + res.data, + foregroundResult, + params + ); + + // if the finalizer transforms data + // update the result + if (finalizerResult) { + res.data = finalizerResult; + } + } + + // check for errors after the finalizer + if (res.error) { + return reject(res.data); + } + + // resolve promise + return resolve(res.data); + } + }); + } + + // @ts-expect-error + window.arweaveWallet = WalletAPI; + + // at the end of the injected script, + // we dispatch the wallet loaded event + dispatchEvent(new CustomEvent("arweaveWalletLoaded", { detail: {} })); + + // send wallet loaded event again if page loaded + window.addEventListener("load", () => { + if (!window.arweaveWallet) return; + dispatchEvent(new CustomEvent("arweaveWalletLoaded", { detail: {} })); + }); + + // TODO: Remove it before to make sure there's no duplicate listener? + + /** Handle events */ + window.addEventListener( + "message", + ( + e: MessageEvent<{ + type: "arconnect_event"; + event: Event; + }> + ) => { + if (e.data.type !== "arconnect_event") return; + + events.emit(e.data.event.name, e.data.event.value); + } + ); +} diff --git a/src/api/module.ts b/src/api/module.ts index 12a12dddd..6458752a1 100644 --- a/src/api/module.ts +++ b/src/api/module.ts @@ -19,14 +19,14 @@ export interface ModuleProperties { permissions: PermissionType[]; } -/** Full API module (background/foreground) */ -export interface Module extends ModuleProperties { - function: ModuleFunction; -} - /** * Function type for background and injected script API functions */ export type ModuleFunction = ( ...params: any[] ) => Promise | ResultType; + +/** Full API module (background/foreground) */ +export interface Module extends ModuleProperties { + function: ModuleFunction; +} diff --git a/src/api/modules/README.md b/src/api/modules/README.md index edb38fd97..685110070 100644 --- a/src/api/modules/README.md +++ b/src/api/modules/README.md @@ -13,3 +13,17 @@ Each module has to be added separately in the two module files (`background.ts` ### Examples For basic examples on how to create a module, refer to the [example module](example/). + +## Message Passing + +### ArConnect Browser Extension + +There are 3 contexts here: + +- Background scripts (service worker). +- Content scripts (injected into the page but with its own isolated context). +- Injected scripts (injected into the page in a `