diff --git a/src/main/deeplink.ts b/src/main/deeplink.ts new file mode 100644 index 000000000..a612b7226 --- /dev/null +++ b/src/main/deeplink.ts @@ -0,0 +1,50 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import path from 'path' + +let initialDeepLinkUri: string | undefined = undefined +let hasDeeplink: boolean = false + +export function registerNeonDeeplink() { + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('neon', process.execPath, [path.resolve(process.argv[1])]) + } + } else { + app.setAsDefaultProtocolClient('neon') + } +} + +export function sendDeeplink(mainWindow: BrowserWindow, deeplinkUrl: string | undefined) { + if (deeplinkUrl) { + mainWindow.webContents.send('deeplink', deeplinkUrl) + } +} + +export function setDeeplink(deeplinkUrl: string | undefined) { + initialDeepLinkUri = deeplinkUrl +} + +export function registerOpenUrl(mainWindow: BrowserWindow | null) { + app.on('open-url', (_event, url) => { + if (!mainWindow) { + initialDeepLinkUri = url + hasDeeplink = true + return + } + + mainWindow.webContents.send('deeplink', url) + }) +} + +export function registerDeeplinkHandler() { + ipcMain.handle('getInitialDeepLinkUri', async () => { + const uri = initialDeepLinkUri + initialDeepLinkUri = undefined + hasDeeplink = false + return uri + }) + + ipcMain.handle('hasDeeplink', async () => { + return hasDeeplink + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 97b76d6ad..c69519945 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,7 @@ import { join } from 'path' import * as packageJson from '../../package.json' import icon from '../../resources/icon.png?asset' +import { registerDeeplinkHandler, registerNeonDeeplink, registerOpenUrl, sendDeeplink, setDeeplink } from './deeplink' import { registerEncryptionHandlers } from './encryption' import { getLedgerTransport, registerLedgerHandler } from './ledger' import { setupSentry } from './sentryElectron' @@ -19,6 +20,8 @@ import { registerWindowHandlers } from './window' const gotTheLock = app.requestSingleInstanceLock() let mainWindow: BrowserWindow | null = null +registerNeonDeeplink() + function createWindow(): void { mainWindow = new BrowserWindow({ title: `Neon Wallet ${packageJson.version}`, @@ -62,10 +65,16 @@ if (!gotTheLock) { } else { setupSentry() - app.on('second-instance', () => { + app.on('second-instance', (_event, commandLine) => { + // The commandLine is an array of strings, where the last element is the deep link URL. + const deeplinkUrl = commandLine.pop() if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() + + sendDeeplink(mainWindow, deeplinkUrl) + } else { + setDeeplink(deeplinkUrl) } }) @@ -89,6 +98,8 @@ if (!gotTheLock) { }) }) + registerOpenUrl(mainWindow) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() @@ -106,4 +117,5 @@ if (!gotTheLock) { registerEncryptionHandlers() registerLedgerHandler(bsAggregator) exposeApiToRenderer(bsAggregator) + registerDeeplinkHandler() } diff --git a/src/renderer/src/@types/i18next-resources.d.ts b/src/renderer/src/@types/i18next-resources.d.ts index ff813fa20..a9a0d6f88 100644 --- a/src/renderer/src/@types/i18next-resources.d.ts +++ b/src/renderer/src/@types/i18next-resources.d.ts @@ -188,6 +188,13 @@ interface Resources { ledgerConnected: 'New Ledger detected \n{{address}}' ledgerDisconnected: 'Ledger disconnected \n{{address}}' } + DappConnection: { + pleaseLogin: 'Please login before connection to a dApp.' + selectAccountModal: { + title: 'Select an account to connect to the dapp.' + selectSourceAccount: 'Select account' + } + } } modals: { import: { diff --git a/src/renderer/src/hooks/useAfterLogin.ts b/src/renderer/src/hooks/useAfterLogin.ts index a6da468b8..5f9ba3749 100644 --- a/src/renderer/src/hooks/useAfterLogin.ts +++ b/src/renderer/src/hooks/useAfterLogin.ts @@ -1,6 +1,7 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useWalletConnectWallet } from '@cityofzion/wallet-connect-sdk-wallet-react' +import { IAccountState } from '@renderer/@types/store' import { UtilsHelper } from '@renderer/helpers/UtilsHelper' import { useAccountsSelector } from './useAccountSelector' @@ -73,7 +74,61 @@ const useRegisterLedgerListeners = () => { }, [accountsRef, createWallet, deleteWallet, importAccount, commonT, t]) } +function isWalletConnectUri(uri) { + return /^wc:.+@\d.*$/g.test(uri) +} + +const useRegisterDeeplinkListeners = () => { + const { modalNavigate } = useModalNavigate() + const { t: commonWc } = useTranslation('hooks', { keyPrefix: 'DappConnection' }) + const [decodedDeeplinkUri, setDecodedDeeplinkUri] = useState(null) + + const handleDeeplink = useCallback(async (uri: string) => { + if (!uri) return + + await window.electron.ipcRenderer.invoke('restore') + + const realUri = uri.split('uri=').pop() + if (!realUri) return + + const decodedUri = decodeURIComponent(realUri) + if (isWalletConnectUri(decodedUri)) { + setDecodedDeeplinkUri(decodedUri) + return + } + + const decodedBase64Uri = atob(decodedUri) + if (isWalletConnectUri(decodedBase64Uri)) { + setDecodedDeeplinkUri(decodedBase64Uri) + } + }, []) + + useEffect(() => { + window.electron.ipcRenderer.invoke('getInitialDeepLinkUri').then(handleDeeplink) + + window.electron.ipcRenderer.once('deeplink', (_event, uri: string) => { + handleDeeplink(uri) + }) + }, [handleDeeplink]) + + useEffect(() => { + if (!decodedDeeplinkUri) return + + modalNavigate('select-account', { + state: { + onSelectAccount: (account: IAccountState) => { + modalNavigate('dapp-connection', { state: { account: account, uri: decodedDeeplinkUri } }) + setDecodedDeeplinkUri(null) + }, + title: commonWc('selectAccountModal.title'), + buttonLabel: commonWc('selectAccountModal.selectSourceAccount'), + }, + }) + }, [commonWc, decodedDeeplinkUri, modalNavigate]) +} + export const useAfterLogin = () => { useRegisterWalletConnectListeners() useRegisterLedgerListeners() + useRegisterDeeplinkListeners() } diff --git a/src/renderer/src/hooks/useBeforeLogin.ts b/src/renderer/src/hooks/useBeforeLogin.ts index 8b30af65a..f48fae611 100644 --- a/src/renderer/src/hooks/useBeforeLogin.ts +++ b/src/renderer/src/hooks/useBeforeLogin.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { StringHelper } from '@renderer/helpers/StringHelper' import { ToastHelper } from '@renderer/helpers/ToastHelper' @@ -70,6 +70,27 @@ const useOverTheAirUpdate = () => { }, [modalNavigate, hasOverTheAirUpdatesRef]) } +const useDeeplinkListeners = () => { + const { t } = useTranslation('hooks', { keyPrefix: 'DappConnection' }) + const [hasDeeplink, setHasDeeplink] = useState(false) + + const handleDeeplink = useCallback(async (hasUri: boolean) => { + await window.electron.ipcRenderer.invoke('restore') + setHasDeeplink(hasUri) + }, []) + + useEffect(() => { + window.electron.ipcRenderer.invoke('hasDeeplink').then(handleDeeplink) + }, [handleDeeplink]) + + useEffect(() => { + if (hasDeeplink) { + ToastHelper.info({ + message: t('pleaseLogin'), + }) + } + }, [hasDeeplink, t]) +} const useNetworkChange = () => { const { networkType } = useNetworkTypeSelector() @@ -102,4 +123,5 @@ export const useBeforeLogin = () => { useOverTheAirUpdate() useNetworkChange() useStoreStartup() + useDeeplinkListeners() } diff --git a/src/renderer/src/locales/en/hooks.json b/src/renderer/src/locales/en/hooks.json index e35bfee63..c24f64f8f 100644 --- a/src/renderer/src/locales/en/hooks.json +++ b/src/renderer/src/locales/en/hooks.json @@ -14,5 +14,12 @@ "useLedgerFlow": { "ledgerConnected": "New Ledger detected \n{{address}}", "ledgerDisconnected": "Ledger disconnected \n{{address}}" + }, + "DappConnection": { + "pleaseLogin": "Please login before connection to a dApp.", + "selectAccountModal": { + "title": "Select an account to connect to the dapp.", + "selectSourceAccount": "Select account" + } } } \ No newline at end of file diff --git a/src/renderer/src/routes/modals/DappConnection/index.tsx b/src/renderer/src/routes/modals/DappConnection/index.tsx index 5b5bb7a38..57ec5d79d 100644 --- a/src/renderer/src/routes/modals/DappConnection/index.tsx +++ b/src/renderer/src/routes/modals/DappConnection/index.tsx @@ -20,15 +20,16 @@ type TFormData = { type TLocationState = { account: IAccountState + uri?: string } export const DappConnectionModal = () => { const { connect, proposals } = useWalletConnectWallet() const { modalNavigate } = useModalNavigate() const { t } = useTranslation('modals', { keyPrefix: 'dappConnection' }) - const { account } = useModalState() + const { account, uri } = useModalState() const { actionData, setData, actionState, setError, handleAct } = useActions({ - url: '', + url: uri ?? '', isConnecting: false, }) diff --git a/src/renderer/src/routes/modals/SelectAccount/index.tsx b/src/renderer/src/routes/modals/SelectAccount/index.tsx index 7f37078f7..7c15f8f11 100644 --- a/src/renderer/src/routes/modals/SelectAccount/index.tsx +++ b/src/renderer/src/routes/modals/SelectAccount/index.tsx @@ -17,7 +17,7 @@ type TLocationState = { onSelectAccount: (contact: IAccountState) => void title: string buttonLabel: string - leftIcon: JSX.Element + leftIcon?: JSX.Element } export const SelectAccount = () => { @@ -53,8 +53,8 @@ export const SelectAccount = () => { if (!selectedAccount) { return } - onSelectAccount(selectedAccount) modalNavigate(-1) + onSelectAccount(selectedAccount) } const filteredWallets = useMemo(() => { diff --git a/src/renderer/src/routes/pages/Send/SelectAccount.tsx b/src/renderer/src/routes/pages/Send/SelectAccount.tsx index ede70e915..c20d1827c 100644 --- a/src/renderer/src/routes/pages/Send/SelectAccount.tsx +++ b/src/renderer/src/routes/pages/Send/SelectAccount.tsx @@ -14,7 +14,7 @@ type TAccountParams = { title: string modalTitle: string buttonLabel: string - leftIcon: JSX.Element + leftIcon?: JSX.Element } export const SelectAccount = ({