diff --git a/apps/browser-extension-wallet/src/components/Crash/Crash.module.scss b/apps/browser-extension-wallet/src/components/Crash/Crash.module.scss new file mode 100644 index 000000000..ef6ded8e1 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Crash/Crash.module.scss @@ -0,0 +1,12 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.crashContainer { + @include flex-center; + flex-direction: column; + height: 100%; + gap: size_unit(2); + + .crashText { + color: var(--text-color-primary); + } +} diff --git a/apps/browser-extension-wallet/src/components/Crash/Crash.tsx b/apps/browser-extension-wallet/src/components/Crash/Crash.tsx new file mode 100644 index 000000000..16d2247e6 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Crash/Crash.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import styles from './Crash.module.scss'; +import { Button } from '@input-output-hk/lace-ui-toolkit'; +import { useRuntime } from '@hooks/useRuntime'; + +export const Crash = (): React.ReactElement => { + const { t } = useTranslation(); + const runtime = useRuntime(); + + return ( +
+

+ {t('general.errors.crash')} +

+ runtime.reload()} + label={t('general.errors.reloadExtension')} + data-testid="crash-reload" + /> +
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/Crash/index.ts b/apps/browser-extension-wallet/src/components/Crash/index.ts new file mode 100644 index 000000000..9c2efe9e2 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Crash/index.ts @@ -0,0 +1 @@ +export * from './Crash'; diff --git a/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx index 80f2732b8..955d14e4e 100644 --- a/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx +++ b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx @@ -9,6 +9,8 @@ import { WalletSetupLayout } from '@views/browser/components'; import { Portal } from '@views/browser/features/wallet-setup/components/Portal'; import { useAnalyticsContext } from '@providers'; import { postHogNamiMigrationActions } from '@providers/AnalyticsProvider/analyticsTracker'; +import { useFatalError } from '@hooks/useFatalError'; +import { Crash } from '@components/Crash'; const urlPath = walletRoutePaths.namiMigration; @@ -23,6 +25,11 @@ export const NamiMigration = (): JSX.Element => { analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.OPEN); }, [analytics]); + const fatalError = useFatalError(); + if (fatalError) { + return ; + } + return ( diff --git a/apps/browser-extension-wallet/src/hooks/useFatalError.ts b/apps/browser-extension-wallet/src/hooks/useFatalError.ts new file mode 100644 index 000000000..bbebedac0 --- /dev/null +++ b/apps/browser-extension-wallet/src/hooks/useFatalError.ts @@ -0,0 +1,59 @@ +import { ObservableWallet } from '@cardano-sdk/wallet'; +import { useObservable } from '@lace/common'; +import { useBackgroundServiceAPIContext } from '@providers'; +import { useWalletStore } from '@src/stores'; +import { useMemo } from 'react'; +import { catchError, take, of, merge, EMPTY } from 'rxjs'; +import { toEmpty } from '@cardano-sdk/util-rxjs'; +import { getErrorMessage } from '@src/utils/get-error-message'; + +const anyError = (wallet: ObservableWallet | undefined) => + wallet + ? merge( + wallet.addresses$, + wallet.assetInfo$, + wallet.balance.rewardAccounts.deposit$, + wallet.balance.rewardAccounts.rewards$, + wallet.balance.utxo.available$, + wallet.balance.utxo.total$, + wallet.balance.utxo.unspendable$, + wallet.currentEpoch$, + wallet.delegation.distribution$, + wallet.delegation.portfolio$, + wallet.delegation.rewardAccounts$, + wallet.delegation.rewardsHistory$, + wallet.eraSummaries$, + wallet.genesisParameters$, + wallet.handles$, + wallet.protocolParameters$, + wallet.governance.isRegisteredAsDRep$, + wallet.publicStakeKeys$, + wallet.syncStatus.isAnyRequestPending$, + wallet.syncStatus.isSettled$, + wallet.syncStatus.isUpToDate$, + wallet.tip$, + wallet.transactions.history$, + wallet.transactions.rollback$, + wallet.utxo.available$, + wallet.utxo.total$, + wallet.utxo.unspendable$ + ).pipe( + toEmpty, + catchError((error) => of({ type: 'base-wallet-error', message: getErrorMessage(error) })), + take(1) + ) + : EMPTY; + +type FatalError = { + type: string; + message: string; +}; + +export const useFatalError = (): FatalError | undefined => { + const backgroundService = useBackgroundServiceAPIContext(); + const unhandledServiceWorkerError = useObservable(backgroundService.unhandledError$); + const { cardanoWallet } = useWalletStore(); + const walletError$ = useMemo(() => anyError(cardanoWallet?.wallet), [cardanoWallet?.wallet]); + const walletError = useObservable(walletError$); + return unhandledServiceWorkerError || walletError; +}; diff --git a/apps/browser-extension-wallet/src/hooks/useRuntime.ts b/apps/browser-extension-wallet/src/hooks/useRuntime.ts new file mode 100644 index 000000000..6018f2399 --- /dev/null +++ b/apps/browser-extension-wallet/src/hooks/useRuntime.ts @@ -0,0 +1,7 @@ +import { runtime } from 'webextension-polyfill'; + +export type LaceRuntime = { reload: () => void }; + +export const useRuntime = (): LaceRuntime => ({ + reload: runtime.reload +}); diff --git a/apps/browser-extension-wallet/src/hooks/useWalletState.ts b/apps/browser-extension-wallet/src/hooks/useWalletState.ts index 1ebe909fe..13dc1510c 100644 --- a/apps/browser-extension-wallet/src/hooks/useWalletState.ts +++ b/apps/browser-extension-wallet/src/hooks/useWalletState.ts @@ -24,14 +24,16 @@ type RemoveObservableNameSuffix = T extends `${infer S}$` ? S : T; type FlattenObservableProperties = T extends Map | String | Number | Array | Date | null | BigInt ? T : T extends object - ? { - [k in keyof T as T[k] extends Function ? never : RemoveObservableNameSuffix]: T[k] extends Observable - ? FlattenObservableProperties - : FlattenObservableProperties; - } - : T; + ? { + [k in keyof T as T[k] extends Function ? never : RemoveObservableNameSuffix]: T[k] extends Observable< + infer O + > + ? FlattenObservableProperties + : FlattenObservableProperties; + } + : T; export type ObservableWalletState = FlattenObservableProperties< - Omit & { + Omit & { transactions: { history$: ObservableWallet['transactions']['history$']; outgoing: Pick; diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts index 20ac32585..5c2eb897d 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts @@ -26,7 +26,8 @@ export const backgroundServiceProperties: RemoteApiProperties getBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise, setBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise, resetStorage: RemoteApiPropertyType.MethodReturningPromise, - backendFailures$: RemoteApiPropertyType.HotObservable + backendFailures$: RemoteApiPropertyType.HotObservable, + unhandledError$: RemoteApiPropertyType.HotObservable }; const { BLOCKFROST_CONFIGS, BLOCKFROST_RATE_LIMIT_CONFIG } = config(); diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts index 9c1b0e1c6..474f0d1ad 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts @@ -12,9 +12,10 @@ import { TokenPrices, CoinPrices, ChangeModeData, - LaceFeaturesApi + LaceFeaturesApi, + UnhandledError } from '../../types'; -import { Subject, of, BehaviorSubject } from 'rxjs'; +import { Subject, of, BehaviorSubject, merge, map, fromEvent } from 'rxjs'; import { walletRoutePaths } from '@routes/wallet-paths'; import { backgroundServiceProperties } from '../config'; import { exposeApi } from '@cardano-sdk/web-extension'; @@ -24,6 +25,7 @@ import { getADAPriceFromBackgroundStorage, closeAllLaceWindows } from '../util'; import { currencies as currenciesMap, currencyCode } from '@providers/currency/constants'; import { clearBackgroundStorage, getBackgroundStorage, setBackgroundStorage } from '../storage'; import { laceFeaturesApiProperties, LACE_FEATURES_CHANNEL } from '../injectUtil'; +import { getErrorMessage } from '@src/utils/get-error-message'; export const requestMessage$ = new Subject(); export const backendFailures$ = new BehaviorSubject(0); @@ -204,6 +206,17 @@ exposeApi( { logger: console, runtime } ); +const toUnhandledError = (error: unknown, type: UnhandledError['type']): UnhandledError => ({ + type, + message: getErrorMessage(error) +}); +const unhandledError$ = merge( + fromEvent(globalThis, 'error').pipe(map((e: ErrorEvent): UnhandledError => toUnhandledError(e, 'error'))), + fromEvent(globalThis, 'unhandledrejection').pipe( + map((e: PromiseRejectionEvent): UnhandledError => toUnhandledError(e, 'unhandledrejection')) + ) +); + exposeApi( { api$: of({ @@ -222,7 +235,8 @@ exposeApi( await clearBackgroundStorage(); await webStorage.local.set({ MIGRATION_STATE: { state: 'up-to-date' } as MigrationState }); }, - backendFailures$ + backendFailures$, + unhandledError$ }), baseChannel: BaseChannels.BACKGROUND_ACTIONS, properties: backgroundServiceProperties diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts index b249b01a3..862818c72 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts @@ -11,6 +11,7 @@ import { createSharedWallet } from '@cardano-sdk/wallet'; import { handleHttpProvider } from '@cardano-sdk/cardano-services-client'; +import { Cardano, HandleProvider } from '@cardano-sdk/core'; import { AnyWallet, StoresFactory, @@ -28,7 +29,6 @@ import { walletRepositoryProperties } from '@cardano-sdk/web-extension'; import { Wallet } from '@lace/cardano'; -import { Cardano, HandleProvider } from '@cardano-sdk/core'; import { cacheActivatedWalletAddressSubscription } from './cache-wallets-address'; import axiosFetchAdapter from '@shiroyasha9/axios-fetch-adapter'; import { SharedWalletScriptKind } from '@lace/core'; diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts index dc0e8ef01..7f2368cd3 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts @@ -1,4 +1,4 @@ -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject, Subject, Observable } from 'rxjs'; import { themes } from '@providers/ThemeProvider'; import { BackgroundStorage, MigrationState } from './storage'; import { CoinPrices } from './prices'; @@ -89,6 +89,11 @@ export type Message = | OpenBrowserMessage | ChangeMode; +export type UnhandledError = { + type: 'error' | 'unhandledrejection'; + message: string; +}; + export type BackgroundService = { handleOpenBrowser: (data: OpenBrowserData, urlSearchParams?: string) => Promise; handleOpenPopup: () => Promise; @@ -103,6 +108,7 @@ export type BackgroundService = { clearBackgroundStorage: typeof clearBackgroundStorage; resetStorage: () => Promise; backendFailures$: BehaviorSubject; + unhandledError$: Observable; }; export type WalletMode = { diff --git a/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx b/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx index 1637cd6b1..8438f8990 100644 --- a/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx +++ b/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx @@ -25,6 +25,8 @@ import { tabs } from 'webextension-polyfill'; import { useTranslation } from 'react-i18next'; import { DappSignDataSuccess } from '@src/features/dapp/components/DappSignDataSuccess'; import { DappSignDataFail } from '@src/features/dapp/components/DappSignDataFail'; +import { Crash } from '@components/Crash'; +import { useFatalError } from '@hooks/useFatalError'; dayjs.extend(duration); @@ -57,17 +59,22 @@ export const DappConnectorView = (): React.ReactElement => { }, [isWalletLocked, cardanoWallet]); const isLoading = useMemo(() => hdDiscoveryStatus !== 'Idle', [hdDiscoveryStatus]); + const fatalError = useFatalError(); useEffect(() => { - if (!isLoading) { + if (!isLoading || fatalError) { document.querySelector('#preloader')?.remove(); } - }, [isLoading]); + }, [isLoading, fatalError]); const onCloseClick = useCallback(() => { tabs.create({ url: `app.html#${walletRoutePaths.setup.home}` }); window.close(); }, []); + if (fatalError) { + return ; + } + if (hasNoAvailableWallet) { return ( diff --git a/apps/browser-extension-wallet/src/routes/PopupView.tsx b/apps/browser-extension-wallet/src/routes/PopupView.tsx index 2fb757c45..fd1c09205 100644 --- a/apps/browser-extension-wallet/src/routes/PopupView.tsx +++ b/apps/browser-extension-wallet/src/routes/PopupView.tsx @@ -12,6 +12,8 @@ import { getValueFromLocalStorage } from '@src/utils/local-storage'; import { MainLoader } from '@components/MainLoader'; import { useAppInit } from '@hooks'; import { ILocalStorage } from '@src/types'; +import { useFatalError } from '@hooks/useFatalError'; +import { Crash } from '@components/Crash'; dayjs.extend(duration); @@ -55,19 +57,24 @@ export const PopupView = (): React.ReactElement => { // (see useEffect in browser-view routes index) }, [isWalletLocked, backgroundServices, currentChain, chainName, cardanoWallet]); + const fatalError = useFatalError(); const isLoaded = useMemo( () => !!cardanoWallet && walletInfo && walletState && inMemoryWallet && initialHdDiscoveryCompleted, [cardanoWallet, walletInfo, walletState, inMemoryWallet, initialHdDiscoveryCompleted] ); useEffect(() => { - if (isLoaded) { + if (isLoaded || fatalError) { document.querySelector('#preloader')?.remove(); } - }, [isLoaded]); + }, [isLoaded, fatalError]); const checkMnemonicVerificationFrequency = () => mnemonicVerificationFrequency && isLastValidationExpired(lastMnemonicVerification, mnemonicVerificationFrequency); + if (fatalError) { + return ; + } + if (checkMnemonicVerificationFrequency() && walletLock) { return ; } diff --git a/apps/browser-extension-wallet/src/utils/get-error-message.ts b/apps/browser-extension-wallet/src/utils/get-error-message.ts new file mode 100644 index 000000000..5b08cb921 --- /dev/null +++ b/apps/browser-extension-wallet/src/utils/get-error-message.ts @@ -0,0 +1,2 @@ +export const getErrorMessage = (error: unknown): string => + error && typeof error.toString === 'function' ? error.toString() : ''; diff --git a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx index 68cc60ec5..31aff3bde 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx @@ -36,6 +36,8 @@ import { BackgroundStorage, Message, MessageTypes } from '@lib/scripts/types'; import { getBackgroundStorage } from '@lib/scripts/background/storage'; import { useTranslation } from 'react-i18next'; import { POPUP_WINDOW_NAMI_TITLE } from '@src/utils/constants'; +import { useFatalError } from '@hooks/useFatalError'; +import { Crash } from '@components/Crash'; export const defaultRoutes: RouteMap = [ { @@ -211,11 +213,17 @@ export const BrowserViewRoutes = ({ routesMap = defaultRoutes }: { routesMap?: R [cardanoWallet, isLoadingWalletInfo, namiMigration?.mode] ); + const fatalError = useFatalError(); + useEffect(() => { - if (isLoaded || isOnboarding || isInNamiMode) { + if (isLoaded || isOnboarding || isInNamiMode || fatalError) { document.querySelector('#preloader')?.remove(); } - }, [isLoaded, isOnboarding, isInNamiMode]); + }, [isLoaded, isOnboarding, isInNamiMode, fatalError]); + + if (fatalError) { + return ; + } if (isInNamiMode) { return ( diff --git a/apps/browser-extension-wallet/src/views/nami-mode/index.tsx b/apps/browser-extension-wallet/src/views/nami-mode/index.tsx index fa5d14b89..6169b88d4 100644 --- a/apps/browser-extension-wallet/src/views/nami-mode/index.tsx +++ b/apps/browser-extension-wallet/src/views/nami-mode/index.tsx @@ -8,6 +8,8 @@ import '../../lib/scripts/keep-alive-ui'; import './index.scss'; import { useBackgroundServiceAPIContext } from '@providers'; import { BrowserViewSections } from '@lib/scripts/types'; +import { Crash } from '@components/Crash'; +import { useFatalError } from '@hooks/useFatalError'; export const NamiPopup = withDappContext((): React.ReactElement => { const { @@ -24,11 +26,13 @@ export const NamiPopup = withDappContext((): React.ReactElement => { () => !!cardanoWallet && walletInfo && walletState && inMemoryWallet && initialHdDiscoveryCompleted && currentChain, [cardanoWallet, walletInfo, walletState, inMemoryWallet, initialHdDiscoveryCompleted, currentChain] ); + + const fatalError = useFatalError(); useEffect(() => { - if (isLoaded) { + if (isLoaded || fatalError) { document.querySelector('#preloader')?.remove(); } - }, [isLoaded]); + }, [isLoaded, fatalError]); useAppInit(); @@ -38,5 +42,9 @@ export const NamiPopup = withDappContext((): React.ReactElement => { } }, [backgroundServices, cardanoWallet, deletingWallet]); + if (fatalError) { + return ; + } + return
{isLoaded ? : }
; }); diff --git a/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx b/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx index aea8cec14..b919989d9 100644 --- a/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx +++ b/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx @@ -6,10 +6,14 @@ import { withDappContext } from '@src/features/dapp/context'; import { NamiDappConnectorView } from './NamiDappConnectorView'; import '../../lib/scripts/keep-alive-ui'; import './index.scss'; +import { useFatalError } from '@hooks/useFatalError'; +import { Crash } from '@components/Crash'; export const NamiDappConnector = withDappContext((): React.ReactElement => { const { hdDiscoveryStatus } = useWalletStore(); const isLoaded = useMemo(() => hdDiscoveryStatus === 'Idle', [hdDiscoveryStatus]); + + const fatalError = useFatalError(); useEffect(() => { if (isLoaded) { document.querySelector('#preloader')?.remove(); @@ -18,5 +22,9 @@ export const NamiDappConnector = withDappContext((): React.ReactElement => { useAppInit(); + if (fatalError) { + return ; + } + return
{isLoaded ? : }
; }); diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/en.json b/packages/translation/src/lib/translations/browser-extension-wallet/en.json index fe16ee0d2..8e0d1c970 100644 --- a/packages/translation/src/lib/translations/browser-extension-wallet/en.json +++ b/packages/translation/src/lib/translations/browser-extension-wallet/en.json @@ -670,6 +670,8 @@ "general.errors.maximumInputCountExceeded": "Maximum Input Count Exceeded", "general.errors.networkError": "Network Error", "general.errors.somethingWentWrong": "Something went wrong, please try again", + "general.errors.crash": "Something went wrong", + "general.errors.reloadExtension": "Reload extension", "general.errors.tryAgain": "Try again", "general.errors.uhoh": "Uh Oh!", "general.errors.utxoFullyDepleted": "UTxO Fully Depleted",