diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 0a557854..31338812 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -6,7 +6,7 @@ import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/ import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; import type { KeypairType } from '@polkadot/util-crypto/types'; -import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types'; +import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types'; import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults'; import { TypeRegistry } from '@polkadot/types'; @@ -518,9 +518,17 @@ export default class Extension { return this.#state.deleteAuthRequest(requestId); } + private updateCurrentTabs ({ urls }: RequestActiveTabsUrlUpdate) { + this.#state.udateCurrentTabsUrl(urls); + } + + private getConnectedTabsUrl () { + return this.#state.getConnectedTabsUrl(); + } + // Weird thought, the eslint override is not needed in Tabs // eslint-disable-next-line @typescript-eslint/require-await - public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], port: chrome.runtime.Port): Promise> { + public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], port?: chrome.runtime.Port): Promise> { switch (type) { case 'pri(authorize.approve)': return this.authorizeApprove(request as RequestAuthorizeApprove); @@ -535,7 +543,7 @@ export default class Extension { return this.deleteAuthRequest(request as string); case 'pri(authorize.requests)': - return this.authorizeSubscribe(id, port); + return port && this.authorizeSubscribe(id, port); case 'pri(authorize.update)': return this.authorizeUpdate(request as RequestUpdateAuthorizedAccounts); @@ -568,7 +576,7 @@ export default class Extension { return this.accountsShow(request as RequestAccountShow); case 'pri(accounts.subscribe)': - return this.accountsSubscribe(id, port); + return port && this.accountsSubscribe(id, port); case 'pri(accounts.tie)': return this.accountsTie(request as RequestAccountTie); @@ -589,7 +597,13 @@ export default class Extension { return this.metadataReject(request as RequestMetadataReject); case 'pri(metadata.requests)': - return this.metadataSubscribe(id, port); + return port && this.metadataSubscribe(id, port); + + case 'pri(activeTabsUrl.update)': + return this.updateCurrentTabs(request as RequestActiveTabsUrlUpdate); + + case 'pri(connectedTabsUrl.get)': + return this.getConnectedTabsUrl(); case 'pri(derivation.create)': return this.derivationCreate(request as RequestDeriveCreate); @@ -628,7 +642,7 @@ export default class Extension { return this.signingIsLocked(request as RequestSigningIsLocked); case 'pri(signing.requests)': - return this.signingSubscribe(id, port); + return port && this.signingSubscribe(id, port); case 'pri(window.open)': return this.windowOpen(request as AllowedPath); diff --git a/packages/extension-base/src/background/handlers/State.ts b/packages/extension-base/src/background/handlers/State.ts index 9609c78c..7dc1b4be 100644 --- a/packages/extension-base/src/background/handlers/State.ts +++ b/packages/extension-base/src/background/handlers/State.ts @@ -153,6 +153,8 @@ export default class State { #windows: number[] = []; + #connectedTabsUrl: string[] = []; + public readonly authSubject: BehaviorSubject = new BehaviorSubject([]); public readonly metaSubject: BehaviorSubject = new BehaviorSubject([]); @@ -267,6 +269,31 @@ export default class State { }; }; + public udateCurrentTabsUrl (urls: string[]) { + const connectedTabs = urls.map((url) => { + let strippedUrl = ''; + + // the assert in stripUrl may throw for new tabs with "chrome://newtab/" + try { + strippedUrl = this.stripUrl(url); + } catch (e) { + console.error(e); + } + + // return the stripped url only if this website is known + return !!strippedUrl && this.authUrls[strippedUrl] + ? strippedUrl + : undefined; + }) + .filter((value) => !!value) as string[]; + + this.#connectedTabsUrl = connectedTabs; + } + + public getConnectedTabsUrl () { + return this.#connectedTabsUrl; + } + public deleteAuthRequest (requestId: string) { delete this.#authRequests[requestId]; this.updateIconAuth(true); diff --git a/packages/extension-base/src/background/handlers/Tabs.ts b/packages/extension-base/src/background/handlers/Tabs.ts index 3020002f..49d9edf8 100644 --- a/packages/extension-base/src/background/handlers/Tabs.ts +++ b/packages/extension-base/src/background/handlers/Tabs.ts @@ -210,7 +210,7 @@ export default class Tabs { return false; } - public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], url: string, port: chrome.runtime.Port): Promise { + public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], url: string, port?: chrome.runtime.Port): Promise { if (type === 'pub(phishing.redirectIfDenied)') { return this.redirectIfPhishing(url); } @@ -227,7 +227,7 @@ export default class Tabs { return this.accountsListAuthorized(url, request as RequestAccountList); case 'pub(accounts.subscribe)': - return this.accountsSubscribeAuthorized(url, id, port); + return port && this.accountsSubscribeAuthorized(url, id, port); case 'pub(accounts.unsubscribe)': return this.accountsUnsubscribe(url, request as RequestAccountUnsubscribe); @@ -248,19 +248,19 @@ export default class Tabs { return this.rpcListProviders(); case 'pub(rpc.send)': - return this.rpcSend(request as RequestRpcSend, port); + return port && this.rpcSend(request as RequestRpcSend, port); case 'pub(rpc.startProvider)': - return this.rpcStartProvider(request as string, port); + return port && this.rpcStartProvider(request as string, port); case 'pub(rpc.subscribe)': - return this.rpcSubscribe(request as RequestRpcSubscribe, id, port); + return port && this.rpcSubscribe(request as RequestRpcSubscribe, id, port); case 'pub(rpc.subscribeConnected)': - return this.rpcSubscribeConnected(request as null, id, port); + return port && this.rpcSubscribeConnected(request as null, id, port); case 'pub(rpc.unsubscribe)': - return this.rpcUnsubscribe(request as RequestRpcUnsubscribe, port); + return port && this.rpcUnsubscribe(request as RequestRpcUnsubscribe, port); default: throw new Error(`Unable to handle message of type ${type}`); diff --git a/packages/extension-base/src/background/handlers/index.ts b/packages/extension-base/src/background/handlers/index.ts index 3fe52467..02450074 100644 --- a/packages/extension-base/src/background/handlers/index.ts +++ b/packages/extension-base/src/background/handlers/index.ts @@ -14,9 +14,11 @@ const state = new State(); const extension = new Extension(state); const tabs = new Tabs(state); -export default function handler ({ id, message, request }: TransportRequestMessage, port: chrome.runtime.Port, extensionPortName = PORT_EXTENSION): void { - const isExtension = port.name === extensionPortName; - const sender = port.sender as chrome.runtime.MessageSender; +export default function handler ({ id, message, request }: TransportRequestMessage, port?: chrome.runtime.Port, extensionPortName = PORT_EXTENSION): void { + const isExtension = !port || port?.name === extensionPortName; + + const sender = port?.sender as chrome.runtime.MessageSender; + const from = isExtension ? 'extension' : (sender.tab && sender.tab.url) || sender.url || ''; diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index 879afad7..0eabcb3c 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -75,6 +75,8 @@ export interface SigningRequest { url: string; } +export type ConnectedTabsUrlResponse = string[] + // [MessageType]: [RequestType, ResponseType, SubscriptionMessageType?] export interface RequestSignatures { // private/internal requests, i.e. from a popup @@ -96,7 +98,9 @@ export interface RequestSignatures { 'pri(authorize.requests)': [RequestAuthorizeSubscribe, boolean, AuthorizeRequest[]]; 'pri(authorize.remove)': [string, ResponseAuthorizeList]; 'pri(authorize.delete.request)': [string, void]; - 'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void] + 'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void]; + 'pri(activeTabsUrl.update)': [RequestActiveTabsUrlUpdate, void]; + 'pri(connectedTabsUrl.get)': [null, ConnectedTabsUrlResponse]; 'pri(derivation.create)': [RequestDeriveCreate, boolean]; 'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate]; 'pri(json.restore)': [RequestJsonRestore, void]; @@ -262,6 +266,10 @@ export interface RequestAccountList { export type RequestAccountSubscribe = null; +export interface RequestActiveTabsUrlUpdate { + urls: string[]; +} + export interface RequestAccountUnsubscribe { id: string; } diff --git a/packages/extension-ui/src/Popup/Accounts/index.tsx b/packages/extension-ui/src/Popup/Accounts/index.tsx index 702a7bde..890fe0dc 100644 --- a/packages/extension-ui/src/Popup/Accounts/index.tsx +++ b/packages/extension-ui/src/Popup/Accounts/index.tsx @@ -50,6 +50,7 @@ function Accounts ({ className }: Props): React.ReactElement {
('Accounts')} diff --git a/packages/extension-ui/src/components/themes.ts b/packages/extension-ui/src/components/themes.ts index d5e5b861..b3ad2964 100644 --- a/packages/extension-ui/src/components/themes.ts +++ b/packages/extension-ui/src/components/themes.ts @@ -21,6 +21,7 @@ const darkTheme = { buttonBackgroundDangerHover: '#D93B3B', buttonBackgroundHover: '#ED9329', buttonTextColor: '#FFFFFF', + connectedDotColor: 'seagreen', errorBorderColor: '#7E3530', errorColor: '#E42F2F', fontFamily: 'Nunito, sans-serif', diff --git a/packages/extension-ui/src/messaging.ts b/packages/extension-ui/src/messaging.ts index 62cd5d0f..2e04a45a 100644 --- a/packages/extension-ui/src/messaging.ts +++ b/packages/extension-ui/src/messaging.ts @@ -1,7 +1,7 @@ // Copyright 2019-2022 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types'; +import type { AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types'; import type { Message } from '@polkadot/extension-base/types'; import type { Chain } from '@polkadot/extension-chains/types'; import type { KeyringPair$Json } from '@polkadot/keyring/types'; @@ -172,6 +172,10 @@ export async function getMetadata (genesisHash?: string | null, isPartial = fals return null; } +export async function getConnectedTabsUrl (): Promise { + return sendMessage('pri(connectedTabsUrl.get)', null); +} + export async function rejectMetaRequest (id: string): Promise { return sendMessage('pri(metadata.reject)', { id }); } diff --git a/packages/extension-ui/src/partials/Header.tsx b/packages/extension-ui/src/partials/Header.tsx index c710d8ea..bea0514c 100644 --- a/packages/extension-ui/src/partials/Header.tsx +++ b/packages/extension-ui/src/partials/Header.tsx @@ -5,7 +5,7 @@ import type { ThemeProps } from '../types'; import { faArrowLeft, faCog, faPlusCircle, faSearch } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; import logo from '../assets/pjs.svg'; @@ -13,6 +13,7 @@ import InputFilter from '../components/InputFilter'; import Link from '../components/Link'; import useOutsideClick from '../hooks/useOutsideClick'; import useTranslation from '../hooks/useTranslation'; +import { getConnectedTabsUrl } from '../messaging'; import MenuAdd from './MenuAdd'; import MenuSettings from './MenuSettings'; @@ -22,22 +23,36 @@ interface Props extends ThemeProps { onFilter?: (filter: string) => void; showAdd?: boolean; showBackArrow?: boolean; + showConnectedAccounts?: boolean; showSearch?: boolean; showSettings?: boolean; smallMargin?: boolean; text?: React.ReactNode; } -function Header ({ children, className = '', onFilter, showAdd, showBackArrow, showSearch, showSettings, smallMargin = false, text }: Props): React.ReactElement { +function Header ({ children, className = '', onFilter, showAdd, showBackArrow, showConnectedAccounts, showSearch, showSettings, smallMargin = false, text }: Props): React.ReactElement { const [isAddOpen, setShowAdd] = useState(false); const [isSettingsOpen, setShowSettings] = useState(false); const [isSearchOpen, setShowSearch] = useState(false); const [filter, setFilter] = useState(''); + const [connectedTabsUrl, setConnectedTabsUrl] = useState([]); const { t } = useTranslation(); const addIconRef = useRef(null); const addMenuRef = useRef(null); const setIconRef = useRef(null); const setMenuRef = useRef(null); + const isConnected = useMemo(() => connectedTabsUrl.length >= 1 + , [connectedTabsUrl]); + + useEffect(() => { + if (!showConnectedAccounts) { + return; + } + + getConnectedTabsUrl() + .then((tabsUrl) => setConnectedTabsUrl(tabsUrl)) + .catch(console.error); + }, [showConnectedAccounts]); useOutsideClick([addIconRef, addMenuRef], (): void => { isAddOpen && setShowAdd(!isAddOpen); @@ -103,6 +118,16 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s {showSearch && (
+ {showConnectedAccounts && !!isConnected && !isSearchOpen && ( +
+ + •Connected + +
+ )} {isSearchOpen && ( ` align-self: center; } + .connectedAccountsWrapper { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + } + + .connectedAccounts { + border: 1px solid ${theme.inputBorderColor}; + border-radius: 4px; + padding: 0 0.5rem; + + .greenDot { + margin-right: 0.3rem; + font-size: 1.5rem; + color: ${theme.connectedDotColor}; + padding-bottom: 0.2rem; + } + } + .searchBarWrapper { flex: 1; display: flex; justify-content: end; - align-items: center;; + align-items: center; .searchIcon { margin-right: 8px; diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 1abb4dbb..5e9d2c80 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -28,6 +28,54 @@ chrome.runtime.onConnect.addListener((port): void => { port.onDisconnect.addListener(() => console.log(`Disconnected from ${port.name}`)); }); +function getActiveTabs () { + // queriing the current active tab in the current window should only ever return 1 tab + // although an array is specified here + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + // get the urls of the active tabs. In the case of new tab the url may be empty or undefined + // we filter these out + const urls: string[] = tabs + .map(({ url }) => url) + .filter((url) => !!url) as string[]; + + const request: TransportRequestMessage<'pri(activeTabsUrl.update)'> = { + id: 'background', + message: 'pri(activeTabsUrl.update)', + origin: 'background', + request: { urls } + }; + + handlers(request); + }); +} + +// listen to tab updates this is fired on url change +chrome.tabs.onUpdated.addListener((_, changeInfo) => { + // we are only interested in url change + if (!changeInfo.url) { + return; + } + + getActiveTabs(); +}); + +// the list of active tab changes when switching window +// in a mutli window setup +chrome.windows.onFocusChanged.addListener(() => + getActiveTabs() +); + +// when clicking on an existing tab or opening a new tab this will be fired +// before the url is entered by users +chrome.tabs.onActivated.addListener(() => { + getActiveTabs(); +}); + +// when deleting a tab this will be fired +chrome.tabs.onRemoved.addListener(() => { + getActiveTabs(); +}); + // initial setup cryptoWaitReady() .then((): void => {