From 6635a06ae47de6758f0d1d878915ab29ae69c169 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 2 Oct 2024 16:39:19 +0100 Subject: [PATCH 01/24] convert AppStateController to typescript --- .eslintrc.js | 2 +- ...e.test.js => app-state-controller.test.ts} | 168 +++- .../controllers/app-state-controller.ts | 859 ++++++++++++++++++ app/scripts/controllers/app-state.js | 637 ------------- .../controllers/mmi-controller.test.ts | 2 +- app/scripts/controllers/mmi-controller.ts | 2 +- app/scripts/lib/ppom/ppom-middleware.ts | 2 +- app/scripts/lib/ppom/ppom-util.test.ts | 2 +- app/scripts/lib/ppom/ppom-util.ts | 2 +- app/scripts/metamask-controller.js | 7 +- .../files-to-convert.json | 1 - shared/constants/mmi-controller.ts | 2 +- 12 files changed, 993 insertions(+), 693 deletions(-) rename app/scripts/controllers/{app-state.test.js => app-state-controller.test.ts} (71%) create mode 100644 app/scripts/controllers/app-state-controller.ts delete mode 100644 app/scripts/controllers/app-state.js diff --git a/.eslintrc.js b/.eslintrc.js index 64c51bd1e50..f2d0aab6211 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -307,7 +307,7 @@ module.exports = { { files: [ '**/__snapshots__/*.snap', - 'app/scripts/controllers/app-state.test.js', + 'app/scripts/controllers/app-state-controller.test.ts', 'app/scripts/controllers/mmi-controller.test.ts', 'app/scripts/metamask-controller.actions.test.js', 'app/scripts/detect-multiple-instances.test.js', diff --git a/app/scripts/controllers/app-state.test.js b/app/scripts/controllers/app-state-controller.test.ts similarity index 71% rename from app/scripts/controllers/app-state.test.js rename to app/scripts/controllers/app-state-controller.test.ts index c9ce8243b05..8b0017b1f0d 100644 --- a/app/scripts/controllers/app-state.test.js +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -1,18 +1,58 @@ -import { ObservableStore } from '@metamask/obs-store'; -import { ORIGIN_METAMASK } from '../../../shared/constants/app'; -import AppStateController from './app-state'; - -let appStateController, mockStore; +import { + AcceptRequest, + AddApprovalRequest, +} from '@metamask/approval-controller'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; +import { + ENVIRONMENT_TYPE_POPUP, + ORIGIN_METAMASK, + POLLING_TOKEN_ENVIRONMENT_TYPES, +} from '../../../shared/constants/app'; +import AppStateController from './app-state-controller'; +import type { + AllowedActions, + AllowedEvents, + AppStateControllerActions, + AppStateControllerEvents, + AppStateControllerState, +} from './app-state-controller'; +import { PreferencesControllerStateChangeEvent } from './preferences-controller'; + +let appStateController: AppStateController; +let controllerMessenger: ControllerMessenger< + | AppStateControllerActions + | AllowedActions + | AddApprovalRequest + | AcceptRequest, + | AppStateControllerEvents + | AllowedEvents + | PreferencesControllerStateChangeEvent + | KeyringControllerQRKeyringStateChangeEvent +>; describe('AppStateController', () => { - mockStore = new ObservableStore(); - const createAppStateController = (initState = {}) => { + const createAppStateController = ( + initState: Partial = {}, + ): AppStateController => { + controllerMessenger = new ControllerMessenger(); + jest.spyOn(ControllerMessenger.prototype, 'call'); + const appStateMessenger = controllerMessenger.getRestricted({ + name: 'AppStateController', + allowedActions: [ + `ApprovalController:addRequest`, + `ApprovalController:acceptRequest`, + ], + allowedEvents: [ + `PreferencesController:stateChange`, + `KeyringController:qrKeyringStateChange`, + ], + }); return new AppStateController({ addUnlockListener: jest.fn(), isUnlocked: jest.fn(() => true), initState, onInactiveTimeout: jest.fn(), - showUnlockRequest: jest.fn(), preferencesStore: { subscribe: jest.fn(), getState: jest.fn(() => ({ @@ -21,33 +61,44 @@ describe('AppStateController', () => { }, })), }, - messenger: { - call: jest.fn(() => ({ - catch: jest.fn(), - })), - subscribe: jest.fn(), + messenger: appStateMessenger, + extension: { + alarms: { + clear: jest.fn(), + create: jest.fn(), + onAlarm: { addListener: jest.fn() }, + }, }, }); }; + const createIsUnlockedMock = (isUnlocked: boolean) => { + return jest + .spyOn( + appStateController as unknown as { isUnlocked: () => boolean }, + 'isUnlocked', + ) + .mockReturnValue(isUnlocked); + }; + beforeEach(() => { - appStateController = createAppStateController({ store: mockStore }); + appStateController = createAppStateController(); }); describe('setOutdatedBrowserWarningLastShown', () => { it('sets the last shown time', () => { appStateController = createAppStateController(); - const date = new Date(); + const timestamp: number = Date.now(); - appStateController.setOutdatedBrowserWarningLastShown(date); + appStateController.setOutdatedBrowserWarningLastShown(timestamp); expect( appStateController.store.getState().outdatedBrowserWarningLastShown, - ).toStrictEqual(date); + ).toStrictEqual(timestamp); }); it('sets outdated browser warning last shown timestamp', () => { - const lastShownTimestamp = Date.now(); + const lastShownTimestamp: number = Date.now(); appStateController = createAppStateController(); const updateStateSpy = jest.spyOn( appStateController.store, @@ -67,10 +118,8 @@ describe('AppStateController', () => { describe('getUnlockPromise', () => { it('waits for unlock if the extension is locked', async () => { - appStateController = createAppStateController(); - const isUnlockedMock = jest - .spyOn(appStateController, 'isUnlocked') - .mockReturnValue(false); + appStateController = createAppStateController({}); + const isUnlockedMock = createIsUnlockedMock(false); const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); appStateController.getUnlockPromise(true); @@ -80,9 +129,7 @@ describe('AppStateController', () => { it('resolves immediately if the extension is already unlocked', async () => { appStateController = createAppStateController(); - const isUnlockedMock = jest - .spyOn(appStateController, 'isUnlocked') - .mockReturnValue(true); + const isUnlockedMock = createIsUnlockedMock(true); await expect( appStateController.getUnlockPromise(false), @@ -95,20 +142,27 @@ describe('AppStateController', () => { describe('waitForUnlock', () => { it('resolves immediately if already unlocked', async () => { const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn = jest.fn(); + const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, false); expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(0); + expect(controllerMessenger.call).toHaveBeenCalledTimes(0); }); it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { - jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + const addRequestSpy = jest.fn().mockImplementation(() => ({ + catch: jest.fn(), + })); + controllerMessenger.registerActionHandler( + 'ApprovalController:addRequest', + addRequestSpy, + ); + createIsUnlockedMock(false); - const resolveFn = jest.fn(); + const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, true); - expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(1); - expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + expect(controllerMessenger.call).toHaveBeenCalledTimes(1); + expect(controllerMessenger.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -122,22 +176,29 @@ describe('AppStateController', () => { describe('handleUnlock', () => { beforeEach(() => { - jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + createIsUnlockedMock(false); }); afterEach(() => { jest.clearAllMocks(); }); it('accepts approval request revolving all the related promises', async () => { + const addRequestSpy = jest.fn().mockImplementation(() => ({ + catch: jest.fn(), + })); + controllerMessenger.registerActionHandler( + 'ApprovalController:addRequest', + addRequestSpy, + ); const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn = jest.fn(); + const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, true); appStateController.handleUnlock(); expect(emitSpy).toHaveBeenCalled(); expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(appStateController.messagingSystem.call).toHaveBeenCalled(); - expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + expect(controllerMessenger.call).toHaveBeenCalled(); + expect(controllerMessenger.call).toHaveBeenCalledWith( 'ApprovalController:acceptRequest', expect.any(String), ); @@ -173,7 +234,7 @@ describe('AppStateController', () => { describe('setRecoveryPhraseReminderLastShown', () => { it('sets the last shown time of recovery phrase reminder', () => { - const timestamp = Date.now(); + const timestamp: number = Date.now(); appStateController.setRecoveryPhraseReminderLastShown(timestamp); expect( @@ -184,7 +245,10 @@ describe('AppStateController', () => { describe('setLastActiveTime', () => { it('sets the last active time to the current time', () => { - const spy = jest.spyOn(appStateController, '_resetTimer'); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); appStateController.setLastActiveTime(); expect(spy).toHaveBeenCalled(); @@ -205,7 +269,8 @@ describe('AppStateController', () => { describe('addPollingToken', () => { it('adds a pollingToken for a given environmentType', () => { - const pollingTokenType = 'popupGasPollTokens'; + const pollingTokenType = + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; appStateController.addPollingToken('token1', pollingTokenType); expect(appStateController.store.getState()[pollingTokenType]).toContain( 'token1', @@ -215,7 +280,8 @@ describe('AppStateController', () => { describe('removePollingToken', () => { it('removes a pollingToken for a given environmentType', () => { - const pollingTokenType = 'popupGasPollTokens'; + const pollingTokenType = + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; appStateController.addPollingToken('token1', pollingTokenType); appStateController.removePollingToken('token1', pollingTokenType); expect( @@ -269,7 +335,7 @@ describe('AppStateController', () => { describe('setCurrentPopupId', () => { it('sets the currentPopupId in the appState', () => { - const popupId = 'popup1'; + const popupId = 12345; appStateController.setCurrentPopupId(popupId); expect(appStateController.store.getState().currentPopupId).toBe(popupId); @@ -278,7 +344,7 @@ describe('AppStateController', () => { describe('getCurrentPopupId', () => { it('retrieves the currentPopupId saved in the appState', () => { - const popupId = 'popup1'; + const popupId = 54321; appStateController.setCurrentPopupId(popupId); expect(appStateController.getCurrentPopupId()).toBe(popupId); @@ -287,7 +353,7 @@ describe('AppStateController', () => { describe('setFirstTimeUsedNetwork', () => { it('updates the array of the first time used networks', () => { - const chainId = '0x1'; + const chainId: string = '0x1'; appStateController.setFirstTimeUsedNetwork(chainId); expect(appStateController.store.getState().usedNetworks[chainId]).toBe( @@ -298,7 +364,11 @@ describe('AppStateController', () => { describe('setLastInteractedConfirmationInfo', () => { it('sets information about last confirmation user has interacted with', () => { - const lastInteractedConfirmationInfo = { + const lastInteractedConfirmationInfo: { + id: string; + chainId: string; + timestamp: number; + } = { id: '123', chainId: '0x1', timestamp: new Date().getTime(), @@ -344,7 +414,10 @@ describe('AppStateController', () => { 'updateState', ); - const mockParams = { url: 'https://example.com', oldRefreshToken: 'old' }; + const mockParams: { url: string; oldRefreshToken: string } = { + url: 'https://example.com', + oldRefreshToken: 'old', + }; appStateController.showInteractiveReplacementTokenBanner(mockParams); @@ -363,7 +436,10 @@ describe('AppStateController', () => { 'updateState', ); - const mockParams = { fromAddress: '0x', custodyId: 'custodyId' }; + const mockParams: { fromAddress: string; custodyId: string } = { + fromAddress: '0x', + custodyId: 'custodyId', + }; appStateController.setCustodianDeepLink(mockParams); @@ -382,7 +458,7 @@ describe('AppStateController', () => { 'updateState', ); - const mockParams = 'some message'; + const mockParams: string = 'some message'; appStateController.setNoteToTraderMessage(mockParams); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts new file mode 100644 index 00000000000..267b9aae732 --- /dev/null +++ b/app/scripts/controllers/app-state-controller.ts @@ -0,0 +1,859 @@ +import EventEmitter from 'events'; +import { ObservableStore } from '@metamask/obs-store'; +import { v4 as uuid } from 'uuid'; +import log from 'loglevel'; +import { ApprovalType } from '@metamask/controller-utils'; +import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; +import { RestrictedControllerMessenger } from '@metamask/base-controller'; +import { + AcceptRequest, + AddApprovalRequest, +} from '@metamask/approval-controller'; +import { Json } from '@metamask/utils'; +import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; +import { MINUTE } from '../../../shared/constants/time'; +import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; +import { isManifestV3 } from '../../../shared/modules/mv3.utils'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { isBeta } from '../../../ui/helpers/utils/build-types'; +import { + ENVIRONMENT_TYPE_BACKGROUND, + POLLING_TOKEN_ENVIRONMENT_TYPES, + ORIGIN_METAMASK, +} from '../../../shared/constants/app'; +import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; +import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; +import { SecurityAlertSource } from '../../../shared/constants/security-provider'; +import PreferencesController, { + Preferences, + PreferencesControllerState, + PreferencesControllerStateChangeEvent, +} from './preferences-controller'; + +export type AppStateControllerState = { + timeoutMinutes: number; + connectedStatusPopoverHasBeenShown: boolean; + defaultHomeActiveTabName: string | null; + browserEnvironment: Record; + popupGasPollTokens: string[]; + notificationGasPollTokens: string[]; + fullScreenGasPollTokens: string[]; + recoveryPhraseReminderHasBeenShown: boolean; + recoveryPhraseReminderLastShown: number; + outdatedBrowserWarningLastShown: number | null; + nftsDetectionNoticeDismissed: boolean; + showTestnetMessageInDropdown: boolean; + showBetaHeader: boolean; + showPermissionsTour: boolean; + showNetworkBanner: boolean; + showAccountBanner: boolean; + trezorModel: string | null; + currentPopupId?: number; + onboardingDate: number | null; + newPrivacyPolicyToastClickedOrClosed: boolean | null; + newPrivacyPolicyToastShownDate: number | null; + // This key is only used for checking if the user had set advancedGasFee + // prior to Migration 92.3 where we split out the setting to support + // multiple networks. + hadAdvancedGasFeesSetPriorToMigration92_3: boolean; + qrHardware: Json; + nftsDropdownState: Json; + usedNetworks: Record; + surveyLinkLastClickedOrClosed: number | null; + signatureSecurityAlertResponses: Record; + // States used for displaying the changed network toast + switchedNetworkDetails: Record | null; + switchedNetworkNeverShowMessage: boolean; + currentExtensionPopupId: number; + lastInteractedConfirmationInfo?: LastInteractedConfirmationInfo; + termsOfUseLastAgreed?: number; + snapsInstallPrivacyWarningShown?: boolean; + interactiveReplacementToken?: { url: string; oldRefreshToken: string }; + noteToTraderMessage?: string; + custodianDeepLink?: { fromAddress: string; custodyId: string }; +}; + +const controllerName = 'AppStateController'; + +/** + * Returns the state of the {@link AppStateController}. + */ +export type AppStateControllerGetStateAction = { + type: 'AppStateController:getState'; + handler: () => AppStateControllerState; +}; + +/** + * Actions exposed by the {@link AppStateController}. + */ +export type AppStateControllerActions = AppStateControllerGetStateAction; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = AddApprovalRequest | AcceptRequest; + +/** + * Event emitted when the state of the {@link AppStateController} changes. + */ +export type AppStateControllerStateChangeEvent = { + type: 'AppStateController:stateChange'; + payload: [AppStateControllerState, []]; +}; + +/** + * Events emitted by {@link AppStateController}. + */ +export type AppStateControllerEvents = AppStateControllerStateChangeEvent; + +/** + * Events that this controller is allowed to subscribe. + */ +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | KeyringControllerQRKeyringStateChangeEvent; + +export type AppStateControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + AppStateControllerActions | AllowedActions, + AppStateControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +type PollingTokenType = + | 'popupGasPollTokens' + | 'notificationGasPollTokens' + | 'fullScreenGasPollTokens'; + +type SecurityAlertResponse = { + block?: number; + description?: string; + features?: string[]; + providerRequestsCount?: Record; + reason: string; + result_type: string; + securityAlertId?: string; + source?: SecurityAlertSource; +}; + +type AppStateControllerOptions = { + addUnlockListener: (callback: () => void) => void; + isUnlocked: () => boolean; + initState?: Partial; + onInactiveTimeout?: () => void; + preferencesStore: ObservableStore; + messenger: AppStateControllerMessenger; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extension: any; +}; + +const getInitState = ( + initState?: Partial, +): AppStateControllerState => ({ + timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, + connectedStatusPopoverHasBeenShown: true, + defaultHomeActiveTabName: null, + browserEnvironment: {}, + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: new Date().getTime(), + outdatedBrowserWarningLastShown: null, + nftsDetectionNoticeDismissed: false, + showTestnetMessageInDropdown: true, + showBetaHeader: isBeta(), + showPermissionsTour: true, + showNetworkBanner: true, + showAccountBanner: true, + trezorModel: null, + onboardingDate: null, + newPrivacyPolicyToastClickedOrClosed: null, + newPrivacyPolicyToastShownDate: null, + hadAdvancedGasFeesSetPriorToMigration92_3: false, + ...initState, + qrHardware: {}, + nftsDropdownState: {}, + usedNetworks: { + '0x1': true, + '0x5': true, + '0x539': true, + }, + surveyLinkLastClickedOrClosed: null, + signatureSecurityAlertResponses: {}, + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage: false, + currentExtensionPopupId: 0, +}); + +export default class AppStateController extends EventEmitter { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private extension: any; + + private onInactiveTimeout: () => void; + + store: ObservableStore; + + private timer: NodeJS.Timeout | null; + + private isUnlocked: () => boolean; + + private waitingForUnlock: { resolve: () => void }[]; + + private messagingSystem: AppStateControllerMessenger; + + private _approvalRequestId: string | null; + + constructor(opts: AppStateControllerOptions) { + const { + addUnlockListener, + isUnlocked, + initState, + onInactiveTimeout, + preferencesStore, + messenger, + extension, + } = opts; + super(); + + this.extension = extension; + this.onInactiveTimeout = onInactiveTimeout || (() => undefined); + this.store = new ObservableStore(getInitState(initState)); + this.timer = null; + + this.isUnlocked = isUnlocked; + this.waitingForUnlock = []; + addUnlockListener(this.handleUnlock.bind(this)); + + messenger.subscribe( + 'PreferencesController:stateChange', + ({ preferences }: { preferences: Partial }) => { + const currentState = this.store.getState(); + if ( + preferences?.autoLockTimeLimit && + currentState.timeoutMinutes !== preferences.autoLockTimeLimit + ) { + this._setInactiveTimeout(preferences.autoLockTimeLimit); + } + }, + ); + + messenger.subscribe( + 'KeyringController:qrKeyringStateChange', + (qrHardware: Json) => + this.store.updateState({ + qrHardware, + }), + ); + + const { preferences } = preferencesStore.getState(); + + if (preferences.autoLockTimeLimit) { + this._setInactiveTimeout(preferences.autoLockTimeLimit); + } + + this.messagingSystem = messenger; + this._approvalRequestId = null; + } + + /** + * Get a Promise that resolves when the extension is unlocked. + * This Promise will never reject. + * + * @param shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + * @returns A promise that resolves when the extension is + * unlocked, or immediately if the extension is already unlocked. + */ + getUnlockPromise(shouldShowUnlockRequest: boolean): Promise { + return new Promise((resolve) => { + if (this.isUnlocked()) { + resolve(); + } else { + this.waitForUnlock(resolve, shouldShowUnlockRequest); + } + }); + } + + /** + * Adds a Promise's resolve function to the waitingForUnlock queue. + * Also opens the extension popup if specified. + * + * @param resolve - A Promise's resolve function that will + * be called when the extension is unlocked. + * @param shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + */ + waitForUnlock(resolve: () => void, shouldShowUnlockRequest: boolean): void { + this.waitingForUnlock.push({ resolve }); + this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); + if (shouldShowUnlockRequest) { + this._requestApproval(); + } + } + + /** + * Drains the waitingForUnlock queue, resolving all the related Promises. + */ + handleUnlock(): void { + if (this.waitingForUnlock.length > 0) { + while (this.waitingForUnlock.length > 0) { + this.waitingForUnlock.shift()?.resolve(); + } + this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); + } + + this._acceptApproval(); + } + + /** + * Sets the default home tab + * + * @param [defaultHomeActiveTabName] - the tab name + */ + setDefaultHomeActiveTabName(defaultHomeActiveTabName: string | null): void { + this.store.updateState({ + defaultHomeActiveTabName, + }); + } + + /** + * Record that the user has seen the connected status info popover + */ + setConnectedStatusPopoverHasBeenShown(): void { + this.store.updateState({ + connectedStatusPopoverHasBeenShown: true, + }); + } + + /** + * Record that the user has been shown the recovery phrase reminder. + */ + setRecoveryPhraseReminderHasBeenShown(): void { + this.store.updateState({ + recoveryPhraseReminderHasBeenShown: true, + }); + } + + setSurveyLinkLastClickedOrClosed(time: number): void { + this.store.updateState({ + surveyLinkLastClickedOrClosed: time, + }); + } + + setOnboardingDate(): void { + this.store.updateState({ + onboardingDate: Date.now(), + }); + } + + setNewPrivacyPolicyToastClickedOrClosed(): void { + this.store.updateState({ + newPrivacyPolicyToastClickedOrClosed: true, + }); + } + + setNewPrivacyPolicyToastShownDate(time: number): void { + this.store.updateState({ + newPrivacyPolicyToastShownDate: time, + }); + } + + /** + * Record the timestamp of the last time the user has seen the recovery phrase reminder + * + * @param lastShown - timestamp when user was last shown the reminder. + */ + setRecoveryPhraseReminderLastShown(lastShown: number): void { + this.store.updateState({ + recoveryPhraseReminderLastShown: lastShown, + }); + } + + /** + * Record the timestamp of the last time the user has acceoted the terms of use + * + * @param lastAgreed - timestamp when user last accepted the terms of use + */ + setTermsOfUseLastAgreed(lastAgreed: number): void { + this.store.updateState({ + termsOfUseLastAgreed: lastAgreed, + }); + } + + /** + * Record if popover for snaps privacy warning has been shown + * on the first install of a snap. + * + * @param shown - shown status + */ + setSnapsInstallPrivacyWarningShownStatus(shown: boolean): void { + this.store.updateState({ + snapsInstallPrivacyWarningShown: shown, + }); + } + + /** + * Record the timestamp of the last time the user has seen the outdated browser warning + * + * @param lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. + */ + setOutdatedBrowserWarningLastShown(lastShown: number): void { + this.store.updateState({ + outdatedBrowserWarningLastShown: lastShown, + }); + } + + /** + * Sets the last active time to the current time. + */ + setLastActiveTime(): void { + this._resetTimer(); + } + + /** + * Sets the inactive timeout for the app + * + * @param timeoutMinutes - The inactive timeout in minutes. + */ + private _setInactiveTimeout(timeoutMinutes: number): void { + this.store.updateState({ + timeoutMinutes, + }); + + this._resetTimer(); + } + + /** + * Resets the internal inactive timer + * + * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new + * timer will not be created. + * + */ + private _resetTimer(): void { + const { timeoutMinutes } = this.store.getState(); + + if (this.timer) { + clearTimeout(this.timer); + } else if (isManifestV3) { + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + + if (!timeoutMinutes) { + return; + } + + // This is a temporary fix until we add a state migration. + // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, + // it was possible for timeoutMinutes to be saved as a string, as explained + // in PR 25109. `alarms.create` will fail in that case. We are + // converting this to a number here to prevent that failure. Once + // we add a migration to update the malformed state to the right type, + // we will remove this conversion. + const timeoutToSet = Number(timeoutMinutes); + + if (isManifestV3) { + this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { + delayInMinutes: timeoutToSet, + periodInMinutes: timeoutToSet, + }); + this.extension.alarms.onAlarm.addListener( + (alarmInfo: { name: string }) => { + if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { + this.onInactiveTimeout(); + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + }, + ); + } else { + this.timer = setTimeout( + () => this.onInactiveTimeout(), + timeoutToSet * MINUTE, + ); + } + } + + /** + * Sets the current browser and OS environment + * + * @param os + * @param browser + */ + setBrowserEnvironment(os: string, browser: string): void { + this.store.updateState({ browserEnvironment: { os, browser } }); + } + + /** + * Adds a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + addPollingToken( + pollingToken: string, + pollingTokenType: PollingTokenType, + ): void { + if ( + pollingTokenType.toString() !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + if (this.#isValidPollingTokenType(pollingTokenType)) { + this.#updatePollingTokens(pollingToken, pollingTokenType); + } + } + } + + /** + * Updates the polling token in the state. + * + * @param pollingToken + * @param pollingTokenType + */ + #updatePollingTokens( + pollingToken: string, + pollingTokenType: PollingTokenType, + ) { + const currentTokens: string[] = this.store.getState()[pollingTokenType]; + this.store.updateState({ + [pollingTokenType]: [...currentTokens, pollingToken], + }); + } + + /** + * removes a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + removePollingToken( + pollingToken: string, + pollingTokenType: PollingTokenType, + ): void { + if ( + pollingTokenType.toString() !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + const currentTokens: string[] = this.store.getState()[pollingTokenType]; + if (this.#isValidPollingTokenType(pollingTokenType)) { + this.store.updateState({ + [pollingTokenType]: currentTokens.filter( + (token: string) => token !== pollingToken, + ), + }); + } + } + } + + /** + * Validates whether the given polling token type is a valid one. + * + * @param pollingTokenType + * @returns true if valid, false otherwise. + */ + #isValidPollingTokenType(pollingTokenType: PollingTokenType): boolean { + const validTokenTypes: PollingTokenType[] = [ + 'popupGasPollTokens', + 'notificationGasPollTokens', + 'fullScreenGasPollTokens', + ]; + + return validTokenTypes.includes(pollingTokenType); + } + + /** + * clears all pollingTokens + */ + clearPollingTokens(): void { + this.store.updateState({ + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + }); + } + + /** + * Sets whether the testnet dismissal link should be shown in the network dropdown + * + * @param showTestnetMessageInDropdown + */ + setShowTestnetMessageInDropdown(showTestnetMessageInDropdown: boolean): void { + this.store.updateState({ showTestnetMessageInDropdown }); + } + + /** + * Sets whether the beta notification heading on the home page + * + * @param showBetaHeader + */ + setShowBetaHeader(showBetaHeader: boolean): void { + this.store.updateState({ showBetaHeader }); + } + + /** + * Sets whether the permissions tour should be shown to the user + * + * @param showPermissionsTour + */ + setShowPermissionsTour(showPermissionsTour: boolean): void { + this.store.updateState({ showPermissionsTour }); + } + + /** + * Sets whether the Network Banner should be shown + * + * @param showNetworkBanner + */ + setShowNetworkBanner(showNetworkBanner: boolean): void { + this.store.updateState({ showNetworkBanner }); + } + + /** + * Sets whether the Account Banner should be shown + * + * @param showAccountBanner + */ + setShowAccountBanner(showAccountBanner: boolean): void { + this.store.updateState({ showAccountBanner }); + } + + /** + * Sets a unique ID for the current extension popup + * + * @param currentExtensionPopupId + */ + setCurrentExtensionPopupId(currentExtensionPopupId: number): void { + this.store.updateState({ currentExtensionPopupId }); + } + + /** + * Sets an object with networkName and appName + * or `null` if the message is meant to be cleared + * + * @param switchedNetworkDetails - Details about the network that MetaMask just switched to. + */ + setSwitchedNetworkDetails( + switchedNetworkDetails: { origin: string; networkClientId: string } | null, + ): void { + this.store.updateState({ switchedNetworkDetails }); + } + + /** + * Clears the switched network details in state + */ + clearSwitchedNetworkDetails(): void { + this.store.updateState({ switchedNetworkDetails: null }); + } + + /** + * Remembers if the user prefers to never see the + * network switched message again + * + * @param switchedNetworkNeverShowMessage + */ + setSwitchedNetworkNeverShowMessage( + switchedNetworkNeverShowMessage: boolean, + ): void { + this.store.updateState({ + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage, + }); + } + + /** + * Sets a property indicating the model of the user's Trezor hardware wallet + * + * @param trezorModel - The Trezor model. + */ + setTrezorModel(trezorModel: string | null): void { + this.store.updateState({ trezorModel }); + } + + /** + * A setter for the `nftsDropdownState` property + * + * @param nftsDropdownState + */ + updateNftDropDownState(nftsDropdownState: Json): void { + this.store.updateState({ + nftsDropdownState, + }); + } + + /** + * Updates the array of the first time used networks + * + * @param chainId + */ + setFirstTimeUsedNetwork(chainId: string): void { + const currentState = this.store.getState(); + const { usedNetworks } = currentState; + usedNetworks[chainId] = true; + + this.store.updateState({ usedNetworks }); + } + + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + /** + * Set the interactive replacement token with a url and the old refresh token + * + * @param opts + * @param opts.url + * @param opts.oldRefreshToken + */ + showInteractiveReplacementTokenBanner({ + url, + oldRefreshToken, + }: { + url: string; + oldRefreshToken: string; + }): void { + this.store.updateState({ + interactiveReplacementToken: { + url, + oldRefreshToken, + }, + }); + } + + /** + * Set the setCustodianDeepLink with the fromAddress and custodyId + * + * @param opts + * @param opts.fromAddress + * @param opts.custodyId + */ + setCustodianDeepLink({ + fromAddress, + custodyId, + }: { + fromAddress: string; + custodyId: string; + }): void { + this.store.updateState({ + custodianDeepLink: { fromAddress, custodyId }, + }); + } + + setNoteToTraderMessage(message: string): void { + this.store.updateState({ + noteToTraderMessage: message, + }); + } + + ///: END:ONLY_INCLUDE_IF + + getSignatureSecurityAlertResponse( + securityAlertId: string, + ): SecurityAlertResponse { + return this.store.getState().signatureSecurityAlertResponses[ + securityAlertId + ]; + } + + addSignatureSecurityAlertResponse( + securityAlertResponse: SecurityAlertResponse, + ): void { + const currentState = this.store.getState(); + const { signatureSecurityAlertResponses } = currentState; + if (securityAlertResponse.securityAlertId) { + this.store.updateState({ + signatureSecurityAlertResponses: { + ...signatureSecurityAlertResponses, + [String(securityAlertResponse.securityAlertId)]: + securityAlertResponse, + }, + }); + } + } + + /** + * A setter for the currentPopupId which indicates the id of popup window that's currently active + * + * @param currentPopupId + */ + setCurrentPopupId(currentPopupId: number): void { + this.store.updateState({ + currentPopupId, + }); + } + + /** + * The function returns information about the last confirmation user interacted with + * + * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. + */ + getLastInteractedConfirmationInfo(): + | LastInteractedConfirmationInfo + | undefined { + return this.store.getState().lastInteractedConfirmationInfo; + } + + /** + * Update the information about the last confirmation user interacted with + * + * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. + */ + setLastInteractedConfirmationInfo( + lastInteractedConfirmationInfo: LastInteractedConfirmationInfo | undefined, + ): void { + this.store.updateState({ + lastInteractedConfirmationInfo, + }); + } + + /** + * A getter to retrieve currentPopupId saved in the appState + */ + getCurrentPopupId(): number | undefined { + return this.store.getState().currentPopupId; + } + + private _requestApproval(): void { + // If we already have a pending request this is a no-op + if (this._approvalRequestId) { + return; + } + this._approvalRequestId = uuid(); + + this.messagingSystem + .call( + 'ApprovalController:addRequest', + { + id: this._approvalRequestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.Unlock, + }, + true, + ) + .catch(() => { + // If the promise fails, we allow a new popup to be triggered + this._approvalRequestId = null; + }); + } + + // Override emit method to provide strong typing for events + emit(event: string) { + return super.emit(event); + } + + private _acceptApproval(): void { + if (!this._approvalRequestId) { + return; + } + try { + this.messagingSystem.call( + 'ApprovalController:acceptRequest', + this._approvalRequestId, + ); + } catch (error) { + log.error('Failed to unlock approval request', error); + } + + this._approvalRequestId = null; + } +} diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js deleted file mode 100644 index 32498a1c8bc..00000000000 --- a/app/scripts/controllers/app-state.js +++ /dev/null @@ -1,637 +0,0 @@ -import EventEmitter from 'events'; -import { ObservableStore } from '@metamask/obs-store'; -import { v4 as uuid } from 'uuid'; -import log from 'loglevel'; -import { ApprovalType } from '@metamask/controller-utils'; -import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; -import { MINUTE } from '../../../shared/constants/time'; -import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; -import { isManifestV3 } from '../../../shared/modules/mv3.utils'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { isBeta } from '../../../ui/helpers/utils/build-types'; -import { - ENVIRONMENT_TYPE_BACKGROUND, - POLLING_TOKEN_ENVIRONMENT_TYPES, - ORIGIN_METAMASK, -} from '../../../shared/constants/app'; -import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; - -/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ - -export default class AppStateController extends EventEmitter { - /** - * @param {object} opts - */ - constructor(opts = {}) { - const { - addUnlockListener, - isUnlocked, - initState, - onInactiveTimeout, - preferencesStore, - messenger, - extension, - } = opts; - super(); - - this.extension = extension; - this.onInactiveTimeout = onInactiveTimeout || (() => undefined); - this.store = new ObservableStore({ - timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, - connectedStatusPopoverHasBeenShown: true, - defaultHomeActiveTabName: null, - browserEnvironment: {}, - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - recoveryPhraseReminderHasBeenShown: false, - recoveryPhraseReminderLastShown: new Date().getTime(), - outdatedBrowserWarningLastShown: null, - nftsDetectionNoticeDismissed: false, - showTestnetMessageInDropdown: true, - showBetaHeader: isBeta(), - showPermissionsTour: true, - showNetworkBanner: true, - showAccountBanner: true, - trezorModel: null, - currentPopupId: undefined, - onboardingDate: null, - newPrivacyPolicyToastClickedOrClosed: null, - newPrivacyPolicyToastShownDate: null, - // This key is only used for checking if the user had set advancedGasFee - // prior to Migration 92.3 where we split out the setting to support - // multiple networks. - hadAdvancedGasFeesSetPriorToMigration92_3: false, - ...initState, - qrHardware: {}, - nftsDropdownState: {}, - usedNetworks: { - '0x1': true, - '0x5': true, - '0x539': true, - }, - surveyLinkLastClickedOrClosed: null, - signatureSecurityAlertResponses: {}, - // States used for displaying the changed network toast - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage: false, - currentExtensionPopupId: 0, - lastInteractedConfirmationInfo: undefined, - }); - this.timer = null; - - this.isUnlocked = isUnlocked; - this.waitingForUnlock = []; - addUnlockListener(this.handleUnlock.bind(this)); - - preferencesStore.subscribe(({ preferences }) => { - const currentState = this.store.getState(); - if (currentState.timeoutMinutes !== preferences.autoLockTimeLimit) { - this._setInactiveTimeout(preferences.autoLockTimeLimit); - } - }); - - messenger.subscribe( - 'KeyringController:qrKeyringStateChange', - (qrHardware) => - this.store.updateState({ - qrHardware, - }), - ); - - const { preferences } = preferencesStore.getState(); - this._setInactiveTimeout(preferences.autoLockTimeLimit); - - this.messagingSystem = messenger; - this._approvalRequestId = null; - } - - /** - * Get a Promise that resolves when the extension is unlocked. - * This Promise will never reject. - * - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - * @returns {Promise} A promise that resolves when the extension is - * unlocked, or immediately if the extension is already unlocked. - */ - getUnlockPromise(shouldShowUnlockRequest) { - return new Promise((resolve) => { - if (this.isUnlocked()) { - resolve(); - } else { - this.waitForUnlock(resolve, shouldShowUnlockRequest); - } - }); - } - - /** - * Adds a Promise's resolve function to the waitingForUnlock queue. - * Also opens the extension popup if specified. - * - * @param {Promise.resolve} resolve - A Promise's resolve function that will - * be called when the extension is unlocked. - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - */ - waitForUnlock(resolve, shouldShowUnlockRequest) { - this.waitingForUnlock.push({ resolve }); - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - if (shouldShowUnlockRequest) { - this._requestApproval(); - } - } - - /** - * Drains the waitingForUnlock queue, resolving all the related Promises. - */ - handleUnlock() { - if (this.waitingForUnlock.length > 0) { - while (this.waitingForUnlock.length > 0) { - this.waitingForUnlock.shift().resolve(); - } - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - } - - this._acceptApproval(); - } - - /** - * Sets the default home tab - * - * @param {string} [defaultHomeActiveTabName] - the tab name - */ - setDefaultHomeActiveTabName(defaultHomeActiveTabName) { - this.store.updateState({ - defaultHomeActiveTabName, - }); - } - - /** - * Record that the user has seen the connected status info popover - */ - setConnectedStatusPopoverHasBeenShown() { - this.store.updateState({ - connectedStatusPopoverHasBeenShown: true, - }); - } - - /** - * Record that the user has been shown the recovery phrase reminder. - */ - setRecoveryPhraseReminderHasBeenShown() { - this.store.updateState({ - recoveryPhraseReminderHasBeenShown: true, - }); - } - - setSurveyLinkLastClickedOrClosed(time) { - this.store.updateState({ - surveyLinkLastClickedOrClosed: time, - }); - } - - setOnboardingDate() { - this.store.updateState({ - onboardingDate: Date.now(), - }); - } - - setNewPrivacyPolicyToastClickedOrClosed() { - this.store.updateState({ - newPrivacyPolicyToastClickedOrClosed: true, - }); - } - - setNewPrivacyPolicyToastShownDate(time) { - this.store.updateState({ - newPrivacyPolicyToastShownDate: time, - }); - } - - /** - * Record the timestamp of the last time the user has seen the recovery phrase reminder - * - * @param {number} lastShown - timestamp when user was last shown the reminder. - */ - setRecoveryPhraseReminderLastShown(lastShown) { - this.store.updateState({ - recoveryPhraseReminderLastShown: lastShown, - }); - } - - /** - * Record the timestamp of the last time the user has acceoted the terms of use - * - * @param {number} lastAgreed - timestamp when user last accepted the terms of use - */ - setTermsOfUseLastAgreed(lastAgreed) { - this.store.updateState({ - termsOfUseLastAgreed: lastAgreed, - }); - } - - /** - * Record if popover for snaps privacy warning has been shown - * on the first install of a snap. - * - * @param {boolean} shown - shown status - */ - setSnapsInstallPrivacyWarningShownStatus(shown) { - this.store.updateState({ - snapsInstallPrivacyWarningShown: shown, - }); - } - - /** - * Record the timestamp of the last time the user has seen the outdated browser warning - * - * @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. - */ - setOutdatedBrowserWarningLastShown(lastShown) { - this.store.updateState({ - outdatedBrowserWarningLastShown: lastShown, - }); - } - - /** - * Sets the last active time to the current time. - */ - setLastActiveTime() { - this._resetTimer(); - } - - /** - * Sets the inactive timeout for the app - * - * @private - * @param {number} timeoutMinutes - The inactive timeout in minutes. - */ - _setInactiveTimeout(timeoutMinutes) { - this.store.updateState({ - timeoutMinutes, - }); - - this._resetTimer(); - } - - /** - * Resets the internal inactive timer - * - * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new - * timer will not be created. - * - * @private - */ - /* eslint-disable no-undef */ - _resetTimer() { - const { timeoutMinutes } = this.store.getState(); - - if (this.timer) { - clearTimeout(this.timer); - } else if (isManifestV3) { - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - - if (!timeoutMinutes) { - return; - } - - // This is a temporary fix until we add a state migration. - // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, - // it was possible for timeoutMinutes to be saved as a string, as explained - // in PR 25109. `alarms.create` will fail in that case. We are - // converting this to a number here to prevent that failure. Once - // we add a migration to update the malformed state to the right type, - // we will remove this conversion. - const timeoutToSet = Number(timeoutMinutes); - - if (isManifestV3) { - this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { - delayInMinutes: timeoutToSet, - periodInMinutes: timeoutToSet, - }); - this.extension.alarms.onAlarm.addListener((alarmInfo) => { - if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - }); - } else { - this.timer = setTimeout( - () => this.onInactiveTimeout(), - timeoutToSet * MINUTE, - ); - } - } - - /** - * Sets the current browser and OS environment - * - * @param os - * @param browser - */ - setBrowserEnvironment(os, browser) { - this.store.updateState({ browserEnvironment: { os, browser } }); - } - - /** - * Adds a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - addPollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: [...prevState, pollingToken], - }); - } - } - - /** - * removes a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - removePollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: prevState.filter((token) => token !== pollingToken), - }); - } - } - - /** - * clears all pollingTokens - */ - clearPollingTokens() { - this.store.updateState({ - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - }); - } - - /** - * Sets whether the testnet dismissal link should be shown in the network dropdown - * - * @param showTestnetMessageInDropdown - */ - setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { - this.store.updateState({ showTestnetMessageInDropdown }); - } - - /** - * Sets whether the beta notification heading on the home page - * - * @param showBetaHeader - */ - setShowBetaHeader(showBetaHeader) { - this.store.updateState({ showBetaHeader }); - } - - /** - * Sets whether the permissions tour should be shown to the user - * - * @param showPermissionsTour - */ - setShowPermissionsTour(showPermissionsTour) { - this.store.updateState({ showPermissionsTour }); - } - - /** - * Sets whether the Network Banner should be shown - * - * @param showNetworkBanner - */ - setShowNetworkBanner(showNetworkBanner) { - this.store.updateState({ showNetworkBanner }); - } - - /** - * Sets whether the Account Banner should be shown - * - * @param showAccountBanner - */ - setShowAccountBanner(showAccountBanner) { - this.store.updateState({ showAccountBanner }); - } - - /** - * Sets a unique ID for the current extension popup - * - * @param currentExtensionPopupId - */ - setCurrentExtensionPopupId(currentExtensionPopupId) { - this.store.updateState({ currentExtensionPopupId }); - } - - /** - * Sets an object with networkName and appName - * or `null` if the message is meant to be cleared - * - * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. - */ - setSwitchedNetworkDetails(switchedNetworkDetails) { - this.store.updateState({ switchedNetworkDetails }); - } - - /** - * Clears the switched network details in state - */ - clearSwitchedNetworkDetails() { - this.store.updateState({ switchedNetworkDetails: null }); - } - - /** - * Remembers if the user prefers to never see the - * network switched message again - * - * @param {boolean} switchedNetworkNeverShowMessage - */ - setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { - this.store.updateState({ - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage, - }); - } - - /** - * Sets a property indicating the model of the user's Trezor hardware wallet - * - * @param trezorModel - The Trezor model. - */ - setTrezorModel(trezorModel) { - this.store.updateState({ trezorModel }); - } - - /** - * A setter for the `nftsDropdownState` property - * - * @param nftsDropdownState - */ - updateNftDropDownState(nftsDropdownState) { - this.store.updateState({ - nftsDropdownState, - }); - } - - /** - * Updates the array of the first time used networks - * - * @param chainId - * @returns {void} - */ - setFirstTimeUsedNetwork(chainId) { - const currentState = this.store.getState(); - const { usedNetworks } = currentState; - usedNetworks[chainId] = true; - - this.store.updateState({ usedNetworks }); - } - - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - /** - * Set the interactive replacement token with a url and the old refresh token - * - * @param {object} opts - * @param opts.url - * @param opts.oldRefreshToken - * @returns {void} - */ - showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) { - this.store.updateState({ - interactiveReplacementToken: { - url, - oldRefreshToken, - }, - }); - } - - /** - * Set the setCustodianDeepLink with the fromAddress and custodyId - * - * @param {object} opts - * @param opts.fromAddress - * @param opts.custodyId - * @returns {void} - */ - setCustodianDeepLink({ fromAddress, custodyId }) { - this.store.updateState({ - custodianDeepLink: { fromAddress, custodyId }, - }); - } - - setNoteToTraderMessage(message) { - this.store.updateState({ - noteToTraderMessage: message, - }); - } - - ///: END:ONLY_INCLUDE_IF - - getSignatureSecurityAlertResponse(securityAlertId) { - return this.store.getState().signatureSecurityAlertResponses[ - securityAlertId - ]; - } - - addSignatureSecurityAlertResponse(securityAlertResponse) { - const currentState = this.store.getState(); - const { signatureSecurityAlertResponses } = currentState; - this.store.updateState({ - signatureSecurityAlertResponses: { - ...signatureSecurityAlertResponses, - [securityAlertResponse.securityAlertId]: securityAlertResponse, - }, - }); - } - - /** - * A setter for the currentPopupId which indicates the id of popup window that's currently active - * - * @param currentPopupId - */ - setCurrentPopupId(currentPopupId) { - this.store.updateState({ - currentPopupId, - }); - } - - /** - * The function returns information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. - */ - getLastInteractedConfirmationInfo() { - return this.store.getState().lastInteractedConfirmationInfo; - } - - /** - * Update the information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. - */ - setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { - this.store.updateState({ - lastInteractedConfirmationInfo, - }); - } - - /** - * A getter to retrieve currentPopupId saved in the appState - */ - getCurrentPopupId() { - return this.store.getState().currentPopupId; - } - - _requestApproval() { - // If we already have a pending request this is a no-op - if (this._approvalRequestId) { - return; - } - this._approvalRequestId = uuid(); - - this.messagingSystem - .call( - 'ApprovalController:addRequest', - { - id: this._approvalRequestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.Unlock, - }, - true, - ) - .catch(() => { - // If the promise fails, we allow a new popup to be triggered - this._approvalRequestId = null; - }); - } - - _acceptApproval() { - if (!this._approvalRequestId) { - return; - } - try { - this.messagingSystem.call( - 'ApprovalController:acceptRequest', - this._approvalRequestId, - ); - } catch (error) { - log.error('Failed to unlock approval request', error); - } - - this._approvalRequestId = null; - } -} diff --git a/app/scripts/controllers/mmi-controller.test.ts b/app/scripts/controllers/mmi-controller.test.ts index 3a9e6cddba6..53b96675824 100644 --- a/app/scripts/controllers/mmi-controller.test.ts +++ b/app/scripts/controllers/mmi-controller.test.ts @@ -18,7 +18,7 @@ import { TEST_NETWORK_TICKER_MAP, } from '../../../shared/constants/network'; import MMIController from './mmi-controller'; -import AppStateController from './app-state'; +import AppStateController from './app-state-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import { mmiKeyringBuilderFactory } from '../mmi-keyring-builder-factory'; import MetaMetricsController from './metametrics'; diff --git a/app/scripts/controllers/mmi-controller.ts b/app/scripts/controllers/mmi-controller.ts index 0c43684d7f5..49d3b480c07 100644 --- a/app/scripts/controllers/mmi-controller.ts +++ b/app/scripts/controllers/mmi-controller.ts @@ -46,7 +46,7 @@ import { getCurrentChainId } from '../../../ui/selectors'; import MetaMetricsController from './metametrics'; import { getPermissionBackgroundApiMethods } from './permissions'; import PreferencesController from './preferences-controller'; -import { AppStateController } from './app-state'; +import AppStateController from './app-state-controller'; type UpdateCustodianTransactionsParameters = { keyring: CustodyKeyring; diff --git a/app/scripts/lib/ppom/ppom-middleware.ts b/app/scripts/lib/ppom/ppom-middleware.ts index 5b9107337a0..3edcc833401 100644 --- a/app/scripts/lib/ppom/ppom-middleware.ts +++ b/app/scripts/lib/ppom/ppom-middleware.ts @@ -15,7 +15,7 @@ import { detectSIWE } from '@metamask/controller-utils'; import { MESSAGE_TYPE } from '../../../../shared/constants/app'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; import PreferencesController from '../../controllers/preferences-controller'; -import { AppStateController } from '../../controllers/app-state'; +import AppStateController from '../../controllers/app-state-controller'; import { LOADING_SECURITY_ALERT_RESPONSE } from '../../../../shared/constants/security-provider'; import { trace, TraceContext, TraceName } from '../../../../shared/lib/trace'; import { diff --git a/app/scripts/lib/ppom/ppom-util.test.ts b/app/scripts/lib/ppom/ppom-util.test.ts index 2fd1932a649..6c8af71b33b 100644 --- a/app/scripts/lib/ppom/ppom-util.test.ts +++ b/app/scripts/lib/ppom/ppom-util.test.ts @@ -13,7 +13,7 @@ import { BlockaidResultType, SecurityAlertSource, } from '../../../../shared/constants/security-provider'; -import { AppStateController } from '../../controllers/app-state'; +import AppStateController from '../../controllers/app-state-controller'; import { generateSecurityAlertId, isChainSupported, diff --git a/app/scripts/lib/ppom/ppom-util.ts b/app/scripts/lib/ppom/ppom-util.ts index 73999061a91..7a1c9246f6d 100644 --- a/app/scripts/lib/ppom/ppom-util.ts +++ b/app/scripts/lib/ppom/ppom-util.ts @@ -15,7 +15,7 @@ import { SecurityAlertSource, } from '../../../../shared/constants/security-provider'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; -import { AppStateController } from '../../controllers/app-state'; +import AppStateController from '../../controllers/app-state-controller'; import { SecurityAlertResponse } from './types'; import { getSecurityAlertsAPISupportedChainIds, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index c03b8802e22..3e031d236d7 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -289,7 +289,7 @@ import { AccountOrderController } from './controllers/account-order'; import createOnboardingMiddleware from './lib/createOnboardingMiddleware'; import { isStreamWritable, setupMultiplex } from './lib/stream-utils'; import PreferencesController from './controllers/preferences-controller'; -import AppStateController from './controllers/app-state'; +import AppStateController from './controllers/app-state-controller'; import AlertController from './controllers/alert'; import OnboardingController from './controllers/onboarding'; import Backup from './lib/backup'; @@ -836,7 +836,10 @@ export default class MetamaskController extends EventEmitter { `${this.approvalController.name}:addRequest`, `${this.approvalController.name}:acceptRequest`, ], - allowedEvents: [`KeyringController:qrKeyringStateChange`], + allowedEvents: [ + `KeyringController:qrKeyringStateChange`, + 'PreferencesController:stateChange', + ], }), extension: this.extension, }); diff --git a/development/ts-migration-dashboard/files-to-convert.json b/development/ts-migration-dashboard/files-to-convert.json index d5063250db1..ef02e65443f 100644 --- a/development/ts-migration-dashboard/files-to-convert.json +++ b/development/ts-migration-dashboard/files-to-convert.json @@ -5,7 +5,6 @@ "app/scripts/constants/on-ramp.js", "app/scripts/contentscript.js", "app/scripts/controllers/alert.js", - "app/scripts/controllers/app-state.js", "app/scripts/controllers/cached-balances.js", "app/scripts/controllers/cached-balances.test.js", "app/scripts/controllers/ens/ens.js", diff --git a/shared/constants/mmi-controller.ts b/shared/constants/mmi-controller.ts index 50cc26ef554..eee210871db 100644 --- a/shared/constants/mmi-controller.ts +++ b/shared/constants/mmi-controller.ts @@ -9,7 +9,7 @@ import { NetworkController } from '@metamask/network-controller'; import PreferencesController from '../../app/scripts/controllers/preferences-controller'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths -import { AppStateController } from '../../app/scripts/controllers/app-state'; +import AppStateController from '../../app/scripts/controllers/app-state-controller'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import AccountTracker from '../../app/scripts/lib/account-tracker'; From 23a89c342f8d98cb719c3e478717b0c5541ccbbe Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 2 Oct 2024 17:11:20 +0100 Subject: [PATCH 02/24] update controller --- .../controllers/app-state-controller.test.ts | 14 +++++--------- app/scripts/controllers/app-state-controller.ts | 7 ++++--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 8b0017b1f0d..7b785d01a38 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -353,7 +353,7 @@ describe('AppStateController', () => { describe('setFirstTimeUsedNetwork', () => { it('updates the array of the first time used networks', () => { - const chainId: string = '0x1'; + const chainId = '0x1'; appStateController.setFirstTimeUsedNetwork(chainId); expect(appStateController.store.getState().usedNetworks[chainId]).toBe( @@ -364,11 +364,7 @@ describe('AppStateController', () => { describe('setLastInteractedConfirmationInfo', () => { it('sets information about last confirmation user has interacted with', () => { - const lastInteractedConfirmationInfo: { - id: string; - chainId: string; - timestamp: number; - } = { + const lastInteractedConfirmationInfo = { id: '123', chainId: '0x1', timestamp: new Date().getTime(), @@ -414,7 +410,7 @@ describe('AppStateController', () => { 'updateState', ); - const mockParams: { url: string; oldRefreshToken: string } = { + const mockParams = { url: 'https://example.com', oldRefreshToken: 'old', }; @@ -436,7 +432,7 @@ describe('AppStateController', () => { 'updateState', ); - const mockParams: { fromAddress: string; custodyId: string } = { + const mockParams = { fromAddress: '0x', custodyId: 'custodyId', }; @@ -458,7 +454,7 @@ describe('AppStateController', () => { 'updateState', ); - const mockParams: string = 'some message'; + const mockParams = 'some message'; appStateController.setNoteToTraderMessage(mockParams); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 267b9aae732..f69dd137f4d 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -25,9 +25,8 @@ import { import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; import { SecurityAlertSource } from '../../../shared/constants/security-provider'; -import PreferencesController, { +import type { Preferences, - PreferencesControllerState, PreferencesControllerStateChangeEvent, } from './preferences-controller'; @@ -143,7 +142,9 @@ type AppStateControllerOptions = { isUnlocked: () => boolean; initState?: Partial; onInactiveTimeout?: () => void; - preferencesStore: ObservableStore; + // TODO: Remove this as soon as PreferencesController upgrade to BaseControllerV2 merges with develop + // eslint-disable-next-line @typescript-eslint/no-explicit-any + preferencesStore: any; messenger: AppStateControllerMessenger; // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any From bfe0d3fb15c36f341fabf47dcd281710ba6cdc1a Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Fri, 4 Oct 2024 17:22:10 +0100 Subject: [PATCH 03/24] changes reflecting from review --- .../controllers/app-state-controller.test.ts | 14 ++-- .../controllers/app-state-controller.ts | 64 ++++++++++--------- app/scripts/controllers/app-state.d.ts | 24 ------- .../controllers/mmi-controller.test.ts | 2 +- app/scripts/controllers/mmi-controller.ts | 2 +- app/scripts/lib/ppom/ppom-middleware.ts | 2 +- app/scripts/lib/ppom/ppom-util.test.ts | 2 +- app/scripts/lib/ppom/ppom-util.ts | 2 +- app/scripts/metamask-controller.js | 2 +- shared/constants/mmi-controller.ts | 2 +- 10 files changed, 50 insertions(+), 66 deletions(-) delete mode 100644 app/scripts/controllers/app-state.d.ts diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 7b785d01a38..c8764e55f6f 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -4,12 +4,13 @@ import { } from '@metamask/approval-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; +import { Browser } from 'webextension-polyfill'; import { ENVIRONMENT_TYPE_POPUP, ORIGIN_METAMASK, POLLING_TOKEN_ENVIRONMENT_TYPES, } from '../../../shared/constants/app'; -import AppStateController from './app-state-controller'; +import { AppStateController } from './app-state-controller'; import type { AllowedActions, AllowedEvents, @@ -19,6 +20,8 @@ import type { } from './app-state-controller'; import { PreferencesControllerStateChangeEvent } from './preferences-controller'; +jest.mock('webextension-polyfill'); + let appStateController: AppStateController; let controllerMessenger: ControllerMessenger< | AppStateControllerActions @@ -64,11 +67,14 @@ describe('AppStateController', () => { messenger: appStateMessenger, extension: { alarms: { - clear: jest.fn(), + getAll: jest.fn(() => Promise.resolve([])), create: jest.fn(), - onAlarm: { addListener: jest.fn() }, + clear: jest.fn(), + onAlarm: { + addListener: jest.fn(), + }, }, - }, + } as unknown as jest.Mocked, }); }; diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index f69dd137f4d..bc13d050536 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -10,6 +10,7 @@ import { AddApprovalRequest, } from '@metamask/approval-controller'; import { Json } from '@metamask/utils'; +import { Browser } from 'webextension-polyfill'; import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; import { MINUTE } from '../../../shared/constants/time'; import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; @@ -24,7 +25,7 @@ import { } from '../../../shared/constants/app'; import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; -import { SecurityAlertSource } from '../../../shared/constants/security-provider'; +import { SecurityAlertResponse } from '../lib/ppom/types'; import type { Preferences, PreferencesControllerStateChangeEvent, @@ -126,33 +127,34 @@ type PollingTokenType = | 'notificationGasPollTokens' | 'fullScreenGasPollTokens'; -type SecurityAlertResponse = { - block?: number; - description?: string; - features?: string[]; - providerRequestsCount?: Record; - reason: string; - result_type: string; - securityAlertId?: string; - source?: SecurityAlertSource; -}; +type AppStateControllerInitState = Partial< + Omit< + AppStateControllerState, + | 'qrHardware' + | 'nftsDropdownState' + | 'usedNetworks' + | 'surveyLinkLastClickedOrClosed' + | 'signatureSecurityAlertResponses' + | 'switchedNetworkDetails' + | 'switchedNetworkNeverShowMessage' + | 'currentExtensionPopupId' + > +>; type AppStateControllerOptions = { addUnlockListener: (callback: () => void) => void; isUnlocked: () => boolean; - initState?: Partial; + initState?: AppStateControllerInitState; onInactiveTimeout?: () => void; // TODO: Remove this as soon as PreferencesController upgrade to BaseControllerV2 merges with develop // eslint-disable-next-line @typescript-eslint/no-explicit-any preferencesStore: any; messenger: AppStateControllerMessenger; - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extension: any; + extension: Browser; }; -const getInitState = ( - initState?: Partial, +const getDefaultAppStateControllerState = ( + initState?: AppStateControllerInitState, ): AppStateControllerState => ({ timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, connectedStatusPopoverHasBeenShown: true, @@ -190,10 +192,8 @@ const getInitState = ( currentExtensionPopupId: 0, }); -export default class AppStateController extends EventEmitter { - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private extension: any; +export class AppStateController extends EventEmitter { + private extension: AppStateControllerOptions['extension']; private onInactiveTimeout: () => void; @@ -207,7 +207,7 @@ export default class AppStateController extends EventEmitter { private messagingSystem: AppStateControllerMessenger; - private _approvalRequestId: string | null; + #approvalRequestId: string | null; constructor(opts: AppStateControllerOptions) { const { @@ -223,7 +223,9 @@ export default class AppStateController extends EventEmitter { this.extension = extension; this.onInactiveTimeout = onInactiveTimeout || (() => undefined); - this.store = new ObservableStore(getInitState(initState)); + this.store = new ObservableStore( + getDefaultAppStateControllerState(initState), + ); this.timer = null; this.isUnlocked = isUnlocked; @@ -258,7 +260,7 @@ export default class AppStateController extends EventEmitter { } this.messagingSystem = messenger; - this._approvalRequestId = null; + this.#approvalRequestId = null; } /** @@ -816,16 +818,16 @@ export default class AppStateController extends EventEmitter { private _requestApproval(): void { // If we already have a pending request this is a no-op - if (this._approvalRequestId) { + if (this.#approvalRequestId) { return; } - this._approvalRequestId = uuid(); + this.#approvalRequestId = uuid(); this.messagingSystem .call( 'ApprovalController:addRequest', { - id: this._approvalRequestId, + id: this.#approvalRequestId, origin: ORIGIN_METAMASK, type: ApprovalType.Unlock, }, @@ -833,7 +835,7 @@ export default class AppStateController extends EventEmitter { ) .catch(() => { // If the promise fails, we allow a new popup to be triggered - this._approvalRequestId = null; + this.#approvalRequestId = null; }); } @@ -843,18 +845,18 @@ export default class AppStateController extends EventEmitter { } private _acceptApproval(): void { - if (!this._approvalRequestId) { + if (!this.#approvalRequestId) { return; } try { this.messagingSystem.call( 'ApprovalController:acceptRequest', - this._approvalRequestId, + this.#approvalRequestId, ); } catch (error) { log.error('Failed to unlock approval request', error); } - this._approvalRequestId = null; + this.#approvalRequestId = null; } } diff --git a/app/scripts/controllers/app-state.d.ts b/app/scripts/controllers/app-state.d.ts deleted file mode 100644 index aa7ffc92eb3..00000000000 --- a/app/scripts/controllers/app-state.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SecurityAlertResponse } from '../lib/ppom/types'; - -export type AppStateController = { - addSignatureSecurityAlertResponse( - securityAlertResponse: SecurityAlertResponse, - ): void; - getUnlockPromise(shouldShowUnlockRequest: boolean): Promise; - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - setCustodianDeepLink({ - fromAddress, - custodyId, - }: { - fromAddress: string; - custodyId: string; - }): void; - showInteractiveReplacementTokenBanner({ - oldRefreshToken, - url, - }: { - oldRefreshToken: string; - url: string; - }): void; - ///: END:ONLY_INCLUDE_IF -}; diff --git a/app/scripts/controllers/mmi-controller.test.ts b/app/scripts/controllers/mmi-controller.test.ts index 53b96675824..614c10a7eda 100644 --- a/app/scripts/controllers/mmi-controller.test.ts +++ b/app/scripts/controllers/mmi-controller.test.ts @@ -18,7 +18,7 @@ import { TEST_NETWORK_TICKER_MAP, } from '../../../shared/constants/network'; import MMIController from './mmi-controller'; -import AppStateController from './app-state-controller'; +import { AppStateController } from './app-state-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import { mmiKeyringBuilderFactory } from '../mmi-keyring-builder-factory'; import MetaMetricsController from './metametrics'; diff --git a/app/scripts/controllers/mmi-controller.ts b/app/scripts/controllers/mmi-controller.ts index 49d3b480c07..f741b8a4cf3 100644 --- a/app/scripts/controllers/mmi-controller.ts +++ b/app/scripts/controllers/mmi-controller.ts @@ -46,7 +46,7 @@ import { getCurrentChainId } from '../../../ui/selectors'; import MetaMetricsController from './metametrics'; import { getPermissionBackgroundApiMethods } from './permissions'; import PreferencesController from './preferences-controller'; -import AppStateController from './app-state-controller'; +import { AppStateController } from './app-state-controller'; type UpdateCustodianTransactionsParameters = { keyring: CustodyKeyring; diff --git a/app/scripts/lib/ppom/ppom-middleware.ts b/app/scripts/lib/ppom/ppom-middleware.ts index 3edcc833401..5fe7c365565 100644 --- a/app/scripts/lib/ppom/ppom-middleware.ts +++ b/app/scripts/lib/ppom/ppom-middleware.ts @@ -15,7 +15,7 @@ import { detectSIWE } from '@metamask/controller-utils'; import { MESSAGE_TYPE } from '../../../../shared/constants/app'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; import PreferencesController from '../../controllers/preferences-controller'; -import AppStateController from '../../controllers/app-state-controller'; +import { AppStateController } from '../../controllers/app-state-controller'; import { LOADING_SECURITY_ALERT_RESPONSE } from '../../../../shared/constants/security-provider'; import { trace, TraceContext, TraceName } from '../../../../shared/lib/trace'; import { diff --git a/app/scripts/lib/ppom/ppom-util.test.ts b/app/scripts/lib/ppom/ppom-util.test.ts index 6c8af71b33b..4ae1b8e7748 100644 --- a/app/scripts/lib/ppom/ppom-util.test.ts +++ b/app/scripts/lib/ppom/ppom-util.test.ts @@ -13,7 +13,7 @@ import { BlockaidResultType, SecurityAlertSource, } from '../../../../shared/constants/security-provider'; -import AppStateController from '../../controllers/app-state-controller'; +import { AppStateController } from '../../controllers/app-state-controller'; import { generateSecurityAlertId, isChainSupported, diff --git a/app/scripts/lib/ppom/ppom-util.ts b/app/scripts/lib/ppom/ppom-util.ts index 7a1c9246f6d..7662c364b65 100644 --- a/app/scripts/lib/ppom/ppom-util.ts +++ b/app/scripts/lib/ppom/ppom-util.ts @@ -15,7 +15,7 @@ import { SecurityAlertSource, } from '../../../../shared/constants/security-provider'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; -import AppStateController from '../../controllers/app-state-controller'; +import { AppStateController } from '../../controllers/app-state-controller'; import { SecurityAlertResponse } from './types'; import { getSecurityAlertsAPISupportedChainIds, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 3e031d236d7..8caae653efa 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -289,7 +289,7 @@ import { AccountOrderController } from './controllers/account-order'; import createOnboardingMiddleware from './lib/createOnboardingMiddleware'; import { isStreamWritable, setupMultiplex } from './lib/stream-utils'; import PreferencesController from './controllers/preferences-controller'; -import AppStateController from './controllers/app-state-controller'; +import { AppStateController } from './controllers/app-state-controller'; import AlertController from './controllers/alert'; import OnboardingController from './controllers/onboarding'; import Backup from './lib/backup'; diff --git a/shared/constants/mmi-controller.ts b/shared/constants/mmi-controller.ts index eee210871db..25e452933b5 100644 --- a/shared/constants/mmi-controller.ts +++ b/shared/constants/mmi-controller.ts @@ -9,7 +9,7 @@ import { NetworkController } from '@metamask/network-controller'; import PreferencesController from '../../app/scripts/controllers/preferences-controller'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths -import AppStateController from '../../app/scripts/controllers/app-state-controller'; +import { AppStateController } from '../../app/scripts/controllers/app-state-controller'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import AccountTracker from '../../app/scripts/lib/account-tracker'; From 744300f03699ac4360dbd6ee263715973e65af7b Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Mon, 14 Oct 2024 13:34:39 +0100 Subject: [PATCH 04/24] replace preferences store with messenger call --- .../controllers/app-state-controller.test.ts | 21 ++++++++++--------- .../controllers/app-state-controller.ts | 12 +++++------ .../controllers/mmi-controller.test.ts | 11 +++++----- app/scripts/metamask-controller.js | 2 +- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index c8764e55f6f..5883a0d80fe 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -45,25 +45,26 @@ describe('AppStateController', () => { allowedActions: [ `ApprovalController:addRequest`, `ApprovalController:acceptRequest`, + `PreferencesController:getState`, ], allowedEvents: [ `PreferencesController:stateChange`, `KeyringController:qrKeyringStateChange`, ], }); + controllerMessenger.registerActionHandler( + 'PreferencesController:getState', + jest.fn().mockReturnValue({ + preferences: { + autoLockTimeLimit: 0, + }, + }), + ); return new AppStateController({ addUnlockListener: jest.fn(), isUnlocked: jest.fn(() => true), initState, onInactiveTimeout: jest.fn(), - preferencesStore: { - subscribe: jest.fn(), - getState: jest.fn(() => ({ - preferences: { - autoLockTimeLimit: 0, - }, - })), - }, messenger: appStateMessenger, extension: { alarms: { @@ -151,7 +152,7 @@ describe('AppStateController', () => { const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, false); expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(controllerMessenger.call).toHaveBeenCalledTimes(0); + expect(controllerMessenger.call).toHaveBeenCalledTimes(1); }); it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { @@ -167,7 +168,7 @@ describe('AppStateController', () => { const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, true); - expect(controllerMessenger.call).toHaveBeenCalledTimes(1); + expect(controllerMessenger.call).toHaveBeenCalledTimes(2); expect(controllerMessenger.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', expect.objectContaining({ diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index bc13d050536..3b585900769 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -28,6 +28,7 @@ import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; import { SecurityAlertResponse } from '../lib/ppom/types'; import type { Preferences, + PreferencesControllerGetStateAction, PreferencesControllerStateChangeEvent, } from './preferences-controller'; @@ -92,7 +93,10 @@ export type AppStateControllerActions = AppStateControllerGetStateAction; /** * Actions that this controller is allowed to call. */ -export type AllowedActions = AddApprovalRequest | AcceptRequest; +export type AllowedActions = + | AddApprovalRequest + | AcceptRequest + | PreferencesControllerGetStateAction; /** * Event emitted when the state of the {@link AppStateController} changes. @@ -146,9 +150,6 @@ type AppStateControllerOptions = { isUnlocked: () => boolean; initState?: AppStateControllerInitState; onInactiveTimeout?: () => void; - // TODO: Remove this as soon as PreferencesController upgrade to BaseControllerV2 merges with develop - // eslint-disable-next-line @typescript-eslint/no-explicit-any - preferencesStore: any; messenger: AppStateControllerMessenger; extension: Browser; }; @@ -215,7 +216,6 @@ export class AppStateController extends EventEmitter { isUnlocked, initState, onInactiveTimeout, - preferencesStore, messenger, extension, } = opts; @@ -253,7 +253,7 @@ export class AppStateController extends EventEmitter { }), ); - const { preferences } = preferencesStore.getState(); + const { preferences } = messenger.call('PreferencesController:getState'); if (preferences.autoLockTimeLimit) { this._setInactiveTimeout(preferences.autoLockTimeLimit); diff --git a/app/scripts/controllers/mmi-controller.test.ts b/app/scripts/controllers/mmi-controller.test.ts index 614c10a7eda..b63f5a80faa 100644 --- a/app/scripts/controllers/mmi-controller.test.ts +++ b/app/scripts/controllers/mmi-controller.test.ts @@ -245,15 +245,14 @@ describe('MMIController', function () { initState: {}, onInactiveTimeout: jest.fn(), showUnlockRequest: jest.fn(), - preferencesStore: { - subscribe: jest.fn(), - getState: jest.fn(() => ({ + messenger: { + ...mockMessenger, + call: jest.fn().mockReturnValue({ preferences: { autoLockTimeLimit: 0, }, - })), - }, - messenger: mockMessenger, + }) + } }), networkController, permissionController, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 8caae653efa..55b32a5390f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -829,12 +829,12 @@ export default class MetamaskController extends EventEmitter { isUnlocked: this.isUnlocked.bind(this), initState: initState.AppStateController, onInactiveTimeout: () => this.setLocked(), - preferencesStore: this.preferencesController.store, messenger: this.controllerMessenger.getRestricted({ name: 'AppStateController', allowedActions: [ `${this.approvalController.name}:addRequest`, `${this.approvalController.name}:acceptRequest`, + `PreferencesController:getState`, ], allowedEvents: [ `KeyringController:qrKeyringStateChange`, From 15dd5e504514322e47fde43996f51e1fb8a87893 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Mon, 14 Oct 2024 15:16:16 +0100 Subject: [PATCH 05/24] replace private identifier in AppState --- .../controllers/app-state-controller.ts | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 2cb6cc669c3..140b6ae753d 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -196,19 +196,19 @@ const getDefaultAppStateControllerState = ( }); export class AppStateController extends EventEmitter { - private extension: AppStateControllerOptions['extension']; + #extension: AppStateControllerOptions['extension']; - private onInactiveTimeout: () => void; + #onInactiveTimeout: () => void; store: ObservableStore; - private timer: NodeJS.Timeout | null; + #timer: NodeJS.Timeout | null; - private isUnlocked: () => boolean; + isUnlocked: () => boolean; - private waitingForUnlock: { resolve: () => void }[]; + #waitingForUnlock: { resolve: () => void }[]; - private messagingSystem: AppStateControllerMessenger; + #messagingSystem: AppStateControllerMessenger; #approvalRequestId: string | null; @@ -223,15 +223,15 @@ export class AppStateController extends EventEmitter { } = opts; super(); - this.extension = extension; - this.onInactiveTimeout = onInactiveTimeout || (() => undefined); + this.#extension = extension; + this.#onInactiveTimeout = onInactiveTimeout || (() => undefined); this.store = new ObservableStore( getDefaultAppStateControllerState(initState), ); - this.timer = null; + this.#timer = null; this.isUnlocked = isUnlocked; - this.waitingForUnlock = []; + this.#waitingForUnlock = []; addUnlockListener(this.handleUnlock.bind(this)); messenger.subscribe( @@ -261,7 +261,7 @@ export class AppStateController extends EventEmitter { this._setInactiveTimeout(preferences.autoLockTimeLimit); } - this.messagingSystem = messenger; + this.#messagingSystem = messenger; this.#approvalRequestId = null; } @@ -294,7 +294,7 @@ export class AppStateController extends EventEmitter { * popup should be opened. */ waitForUnlock(resolve: () => void, shouldShowUnlockRequest: boolean): void { - this.waitingForUnlock.push({ resolve }); + this.#waitingForUnlock.push({ resolve }); this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); if (shouldShowUnlockRequest) { this._requestApproval(); @@ -305,9 +305,9 @@ export class AppStateController extends EventEmitter { * Drains the waitingForUnlock queue, resolving all the related Promises. */ handleUnlock(): void { - if (this.waitingForUnlock.length > 0) { - while (this.waitingForUnlock.length > 0) { - this.waitingForUnlock.shift()?.resolve(); + if (this.#waitingForUnlock.length > 0) { + while (this.#waitingForUnlock.length > 0) { + this.#waitingForUnlock.shift()?.resolve(); } this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); } @@ -449,10 +449,10 @@ export class AppStateController extends EventEmitter { private _resetTimer(): void { const { timeoutMinutes } = this.store.getState(); - if (this.timer) { - clearTimeout(this.timer); + if (this.#timer) { + clearTimeout(this.#timer); } else if (isManifestV3) { - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + this.#extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); } if (!timeoutMinutes) { @@ -469,21 +469,21 @@ export class AppStateController extends EventEmitter { const timeoutToSet = Number(timeoutMinutes); if (isManifestV3) { - this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { + this.#extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { delayInMinutes: timeoutToSet, periodInMinutes: timeoutToSet, }); - this.extension.alarms.onAlarm.addListener( + this.#extension.alarms.onAlarm.addListener( (alarmInfo: { name: string }) => { if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + this.#onInactiveTimeout(); + this.#extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); } }, ); } else { - this.timer = setTimeout( - () => this.onInactiveTimeout(), + this.#timer = setTimeout( + () => this.#onInactiveTimeout(), timeoutToSet * MINUTE, ); } @@ -831,7 +831,7 @@ export class AppStateController extends EventEmitter { } this.#approvalRequestId = uuid(); - this.messagingSystem + this.#messagingSystem .call( 'ApprovalController:addRequest', { @@ -857,7 +857,7 @@ export class AppStateController extends EventEmitter { return; } try { - this.messagingSystem.call( + this.#messagingSystem.call( 'ApprovalController:acceptRequest', this.#approvalRequestId, ); From 041c435e01f67563e9b1dad8f859891e1c32d0b4 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Mon, 14 Oct 2024 15:46:56 +0100 Subject: [PATCH 06/24] unit tests added --- .../controllers/app-state-controller.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 5883a0d80fe..ad034f2d7c7 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -473,4 +473,130 @@ describe('AppStateController', () => { updateStateSpy.mockRestore(); }); }); + + describe('setSurveyLinkLastClickedOrClosed', () => { + it('set the surveyLinkLastClickedOrClosed time', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setSurveyLinkLastClickedOrClosed(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + surveyLinkLastClickedOrClosed: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setOnboardingDate', () => { + it('set the onboardingDate', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setOnboardingDate(); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setLastViewedUserSurvey', () => { + it('set the lastViewedUserSurvey with id 1', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = 1; + + appStateController.setLastViewedUserSurvey(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + lastViewedUserSurvey: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setNewPrivacyPolicyToastClickedOrClosed', () => { + it('set the newPrivacyPolicyToastClickedOrClosed to true', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setNewPrivacyPolicyToastClickedOrClosed(); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect( + appStateController.store.getState() + .newPrivacyPolicyToastClickedOrClosed, + ).toStrictEqual(true); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setNewPrivacyPolicyToastShownDate', () => { + it('set the newPrivacyPolicyToastShownDate', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setNewPrivacyPolicyToastShownDate(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + newPrivacyPolicyToastShownDate: mockParams, + }); + expect( + appStateController.store.getState().newPrivacyPolicyToastShownDate, + ).toStrictEqual(mockParams); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setTermsOfUseLastAgreed', () => { + it('set the termsOfUseLastAgreed timestamp', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setTermsOfUseLastAgreed(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + termsOfUseLastAgreed: mockParams, + }); + expect( + appStateController.store.getState().termsOfUseLastAgreed, + ).toStrictEqual(mockParams); + + updateStateSpy.mockRestore(); + }); + }); }); From 0d56c95cebb58233e4aec6301cae22219d155a35 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Mon, 14 Oct 2024 16:50:50 +0100 Subject: [PATCH 07/24] unit tests added --- .../controllers/app-state-controller.test.ts | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index ad034f2d7c7..655ed12a379 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -18,7 +18,10 @@ import type { AppStateControllerEvents, AppStateControllerState, } from './app-state-controller'; -import { PreferencesControllerStateChangeEvent } from './preferences-controller'; +import { + PreferencesControllerState, + PreferencesControllerStateChangeEvent, +} from './preferences-controller'; jest.mock('webextension-polyfill'); @@ -37,7 +40,10 @@ let controllerMessenger: ControllerMessenger< describe('AppStateController', () => { const createAppStateController = ( initState: Partial = {}, - ): AppStateController => { + ): { + appStateController: AppStateController; + controllerMessenger: typeof controllerMessenger; + } => { controllerMessenger = new ControllerMessenger(); jest.spyOn(ControllerMessenger.prototype, 'call'); const appStateMessenger = controllerMessenger.getRestricted({ @@ -60,7 +66,7 @@ describe('AppStateController', () => { }, }), ); - return new AppStateController({ + appStateController = new AppStateController({ addUnlockListener: jest.fn(), isUnlocked: jest.fn(() => true), initState, @@ -77,6 +83,8 @@ describe('AppStateController', () => { }, } as unknown as jest.Mocked, }); + + return { appStateController, controllerMessenger }; }; const createIsUnlockedMock = (isUnlocked: boolean) => { @@ -89,12 +97,12 @@ describe('AppStateController', () => { }; beforeEach(() => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); }); describe('setOutdatedBrowserWarningLastShown', () => { it('sets the last shown time', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const timestamp: number = Date.now(); appStateController.setOutdatedBrowserWarningLastShown(timestamp); @@ -106,7 +114,7 @@ describe('AppStateController', () => { it('sets outdated browser warning last shown timestamp', () => { const lastShownTimestamp: number = Date.now(); - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -125,7 +133,7 @@ describe('AppStateController', () => { describe('getUnlockPromise', () => { it('waits for unlock if the extension is locked', async () => { - appStateController = createAppStateController({}); + ({ appStateController } = createAppStateController()); const isUnlockedMock = createIsUnlockedMock(false); const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); @@ -135,7 +143,7 @@ describe('AppStateController', () => { }); it('resolves immediately if the extension is already unlocked', async () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const isUnlockedMock = createIsUnlockedMock(true); await expect( @@ -392,7 +400,7 @@ describe('AppStateController', () => { describe('setSnapsInstallPrivacyWarningShownStatus', () => { it('updates the status of snaps install privacy warning', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -411,7 +419,7 @@ describe('AppStateController', () => { describe('institutional', () => { it('set the interactive replacement token with a url and the old refresh token', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -433,7 +441,7 @@ describe('AppStateController', () => { }); it('set the setCustodianDeepLink with the fromAddress and custodyId', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -455,7 +463,7 @@ describe('AppStateController', () => { }); it('set the setNoteToTraderMessage with a message', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -476,7 +484,7 @@ describe('AppStateController', () => { describe('setSurveyLinkLastClickedOrClosed', () => { it('set the surveyLinkLastClickedOrClosed time', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -497,7 +505,7 @@ describe('AppStateController', () => { describe('setOnboardingDate', () => { it('set the onboardingDate', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -513,7 +521,7 @@ describe('AppStateController', () => { describe('setLastViewedUserSurvey', () => { it('set the lastViewedUserSurvey with id 1', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -534,7 +542,7 @@ describe('AppStateController', () => { describe('setNewPrivacyPolicyToastClickedOrClosed', () => { it('set the newPrivacyPolicyToastClickedOrClosed to true', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -554,7 +562,7 @@ describe('AppStateController', () => { describe('setNewPrivacyPolicyToastShownDate', () => { it('set the newPrivacyPolicyToastShownDate', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -578,7 +586,7 @@ describe('AppStateController', () => { describe('setTermsOfUseLastAgreed', () => { it('set the termsOfUseLastAgreed timestamp', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -599,4 +607,24 @@ describe('AppStateController', () => { updateStateSpy.mockRestore(); }); }); + + describe('onPreferencesStateChange', () => { + it('should update the timeoutMinutes with the autoLockTimeLimit', () => { + ({ appStateController, controllerMessenger } = + createAppStateController()); + const timeout = Date.now(); + + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + + expect(appStateController.store.getState().timeoutMinutes).toStrictEqual( + timeout, + ); + }); + }); }); From 5857f4bc3cbbc9a721cb8bca7fb5b4bfdf8171b7 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 13:01:58 +0100 Subject: [PATCH 08/24] app-state.js deleted --- app/scripts/controllers/app-state.js | 644 --------------------------- 1 file changed, 644 deletions(-) delete mode 100644 app/scripts/controllers/app-state.js diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js deleted file mode 100644 index 3d8f9d176fb..00000000000 --- a/app/scripts/controllers/app-state.js +++ /dev/null @@ -1,644 +0,0 @@ -import EventEmitter from 'events'; -import { ObservableStore } from '@metamask/obs-store'; -import { v4 as uuid } from 'uuid'; -import log from 'loglevel'; -import { ApprovalType } from '@metamask/controller-utils'; -import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; -import { MINUTE } from '../../../shared/constants/time'; -import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; -import { isManifestV3 } from '../../../shared/modules/mv3.utils'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { isBeta } from '../../../ui/helpers/utils/build-types'; -import { - ENVIRONMENT_TYPE_BACKGROUND, - POLLING_TOKEN_ENVIRONMENT_TYPES, - ORIGIN_METAMASK, -} from '../../../shared/constants/app'; -import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; - -/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ - -export default class AppStateController extends EventEmitter { - /** - * @param {object} opts - */ - constructor(opts = {}) { - const { - addUnlockListener, - isUnlocked, - initState, - onInactiveTimeout, - preferencesStore, - messenger, - extension, - } = opts; - super(); - - this.extension = extension; - this.onInactiveTimeout = onInactiveTimeout || (() => undefined); - this.store = new ObservableStore({ - timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, - connectedStatusPopoverHasBeenShown: true, - defaultHomeActiveTabName: null, - browserEnvironment: {}, - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - recoveryPhraseReminderHasBeenShown: false, - recoveryPhraseReminderLastShown: new Date().getTime(), - outdatedBrowserWarningLastShown: null, - nftsDetectionNoticeDismissed: false, - showTestnetMessageInDropdown: true, - showBetaHeader: isBeta(), - showPermissionsTour: true, - showNetworkBanner: true, - showAccountBanner: true, - trezorModel: null, - currentPopupId: undefined, - onboardingDate: null, - lastViewedUserSurvey: null, - newPrivacyPolicyToastClickedOrClosed: null, - newPrivacyPolicyToastShownDate: null, - // This key is only used for checking if the user had set advancedGasFee - // prior to Migration 92.3 where we split out the setting to support - // multiple networks. - hadAdvancedGasFeesSetPriorToMigration92_3: false, - ...initState, - qrHardware: {}, - nftsDropdownState: {}, - usedNetworks: { - '0x1': true, - '0x5': true, - '0x539': true, - }, - surveyLinkLastClickedOrClosed: null, - signatureSecurityAlertResponses: {}, - // States used for displaying the changed network toast - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage: false, - currentExtensionPopupId: 0, - lastInteractedConfirmationInfo: undefined, - }); - this.timer = null; - - this.isUnlocked = isUnlocked; - this.waitingForUnlock = []; - addUnlockListener(this.handleUnlock.bind(this)); - - preferencesStore.subscribe(({ preferences }) => { - const currentState = this.store.getState(); - if (currentState.timeoutMinutes !== preferences.autoLockTimeLimit) { - this._setInactiveTimeout(preferences.autoLockTimeLimit); - } - }); - - messenger.subscribe( - 'KeyringController:qrKeyringStateChange', - (qrHardware) => - this.store.updateState({ - qrHardware, - }), - ); - - const { preferences } = preferencesStore.getState(); - this._setInactiveTimeout(preferences.autoLockTimeLimit); - - this.messagingSystem = messenger; - this._approvalRequestId = null; - } - - /** - * Get a Promise that resolves when the extension is unlocked. - * This Promise will never reject. - * - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - * @returns {Promise} A promise that resolves when the extension is - * unlocked, or immediately if the extension is already unlocked. - */ - getUnlockPromise(shouldShowUnlockRequest) { - return new Promise((resolve) => { - if (this.isUnlocked()) { - resolve(); - } else { - this.waitForUnlock(resolve, shouldShowUnlockRequest); - } - }); - } - - /** - * Adds a Promise's resolve function to the waitingForUnlock queue. - * Also opens the extension popup if specified. - * - * @param {Promise.resolve} resolve - A Promise's resolve function that will - * be called when the extension is unlocked. - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - */ - waitForUnlock(resolve, shouldShowUnlockRequest) { - this.waitingForUnlock.push({ resolve }); - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - if (shouldShowUnlockRequest) { - this._requestApproval(); - } - } - - /** - * Drains the waitingForUnlock queue, resolving all the related Promises. - */ - handleUnlock() { - if (this.waitingForUnlock.length > 0) { - while (this.waitingForUnlock.length > 0) { - this.waitingForUnlock.shift().resolve(); - } - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - } - - this._acceptApproval(); - } - - /** - * Sets the default home tab - * - * @param {string} [defaultHomeActiveTabName] - the tab name - */ - setDefaultHomeActiveTabName(defaultHomeActiveTabName) { - this.store.updateState({ - defaultHomeActiveTabName, - }); - } - - /** - * Record that the user has seen the connected status info popover - */ - setConnectedStatusPopoverHasBeenShown() { - this.store.updateState({ - connectedStatusPopoverHasBeenShown: true, - }); - } - - /** - * Record that the user has been shown the recovery phrase reminder. - */ - setRecoveryPhraseReminderHasBeenShown() { - this.store.updateState({ - recoveryPhraseReminderHasBeenShown: true, - }); - } - - setSurveyLinkLastClickedOrClosed(time) { - this.store.updateState({ - surveyLinkLastClickedOrClosed: time, - }); - } - - setOnboardingDate() { - this.store.updateState({ - onboardingDate: Date.now(), - }); - } - - setLastViewedUserSurvey(id) { - this.store.updateState({ - lastViewedUserSurvey: id, - }); - } - - setNewPrivacyPolicyToastClickedOrClosed() { - this.store.updateState({ - newPrivacyPolicyToastClickedOrClosed: true, - }); - } - - setNewPrivacyPolicyToastShownDate(time) { - this.store.updateState({ - newPrivacyPolicyToastShownDate: time, - }); - } - - /** - * Record the timestamp of the last time the user has seen the recovery phrase reminder - * - * @param {number} lastShown - timestamp when user was last shown the reminder. - */ - setRecoveryPhraseReminderLastShown(lastShown) { - this.store.updateState({ - recoveryPhraseReminderLastShown: lastShown, - }); - } - - /** - * Record the timestamp of the last time the user has acceoted the terms of use - * - * @param {number} lastAgreed - timestamp when user last accepted the terms of use - */ - setTermsOfUseLastAgreed(lastAgreed) { - this.store.updateState({ - termsOfUseLastAgreed: lastAgreed, - }); - } - - /** - * Record if popover for snaps privacy warning has been shown - * on the first install of a snap. - * - * @param {boolean} shown - shown status - */ - setSnapsInstallPrivacyWarningShownStatus(shown) { - this.store.updateState({ - snapsInstallPrivacyWarningShown: shown, - }); - } - - /** - * Record the timestamp of the last time the user has seen the outdated browser warning - * - * @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. - */ - setOutdatedBrowserWarningLastShown(lastShown) { - this.store.updateState({ - outdatedBrowserWarningLastShown: lastShown, - }); - } - - /** - * Sets the last active time to the current time. - */ - setLastActiveTime() { - this._resetTimer(); - } - - /** - * Sets the inactive timeout for the app - * - * @private - * @param {number} timeoutMinutes - The inactive timeout in minutes. - */ - _setInactiveTimeout(timeoutMinutes) { - this.store.updateState({ - timeoutMinutes, - }); - - this._resetTimer(); - } - - /** - * Resets the internal inactive timer - * - * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new - * timer will not be created. - * - * @private - */ - /* eslint-disable no-undef */ - _resetTimer() { - const { timeoutMinutes } = this.store.getState(); - - if (this.timer) { - clearTimeout(this.timer); - } else if (isManifestV3) { - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - - if (!timeoutMinutes) { - return; - } - - // This is a temporary fix until we add a state migration. - // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, - // it was possible for timeoutMinutes to be saved as a string, as explained - // in PR 25109. `alarms.create` will fail in that case. We are - // converting this to a number here to prevent that failure. Once - // we add a migration to update the malformed state to the right type, - // we will remove this conversion. - const timeoutToSet = Number(timeoutMinutes); - - if (isManifestV3) { - this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { - delayInMinutes: timeoutToSet, - periodInMinutes: timeoutToSet, - }); - this.extension.alarms.onAlarm.addListener((alarmInfo) => { - if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - }); - } else { - this.timer = setTimeout( - () => this.onInactiveTimeout(), - timeoutToSet * MINUTE, - ); - } - } - - /** - * Sets the current browser and OS environment - * - * @param os - * @param browser - */ - setBrowserEnvironment(os, browser) { - this.store.updateState({ browserEnvironment: { os, browser } }); - } - - /** - * Adds a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - addPollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: [...prevState, pollingToken], - }); - } - } - - /** - * removes a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - removePollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: prevState.filter((token) => token !== pollingToken), - }); - } - } - - /** - * clears all pollingTokens - */ - clearPollingTokens() { - this.store.updateState({ - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - }); - } - - /** - * Sets whether the testnet dismissal link should be shown in the network dropdown - * - * @param showTestnetMessageInDropdown - */ - setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { - this.store.updateState({ showTestnetMessageInDropdown }); - } - - /** - * Sets whether the beta notification heading on the home page - * - * @param showBetaHeader - */ - setShowBetaHeader(showBetaHeader) { - this.store.updateState({ showBetaHeader }); - } - - /** - * Sets whether the permissions tour should be shown to the user - * - * @param showPermissionsTour - */ - setShowPermissionsTour(showPermissionsTour) { - this.store.updateState({ showPermissionsTour }); - } - - /** - * Sets whether the Network Banner should be shown - * - * @param showNetworkBanner - */ - setShowNetworkBanner(showNetworkBanner) { - this.store.updateState({ showNetworkBanner }); - } - - /** - * Sets whether the Account Banner should be shown - * - * @param showAccountBanner - */ - setShowAccountBanner(showAccountBanner) { - this.store.updateState({ showAccountBanner }); - } - - /** - * Sets a unique ID for the current extension popup - * - * @param currentExtensionPopupId - */ - setCurrentExtensionPopupId(currentExtensionPopupId) { - this.store.updateState({ currentExtensionPopupId }); - } - - /** - * Sets an object with networkName and appName - * or `null` if the message is meant to be cleared - * - * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. - */ - setSwitchedNetworkDetails(switchedNetworkDetails) { - this.store.updateState({ switchedNetworkDetails }); - } - - /** - * Clears the switched network details in state - */ - clearSwitchedNetworkDetails() { - this.store.updateState({ switchedNetworkDetails: null }); - } - - /** - * Remembers if the user prefers to never see the - * network switched message again - * - * @param {boolean} switchedNetworkNeverShowMessage - */ - setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { - this.store.updateState({ - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage, - }); - } - - /** - * Sets a property indicating the model of the user's Trezor hardware wallet - * - * @param trezorModel - The Trezor model. - */ - setTrezorModel(trezorModel) { - this.store.updateState({ trezorModel }); - } - - /** - * A setter for the `nftsDropdownState` property - * - * @param nftsDropdownState - */ - updateNftDropDownState(nftsDropdownState) { - this.store.updateState({ - nftsDropdownState, - }); - } - - /** - * Updates the array of the first time used networks - * - * @param chainId - * @returns {void} - */ - setFirstTimeUsedNetwork(chainId) { - const currentState = this.store.getState(); - const { usedNetworks } = currentState; - usedNetworks[chainId] = true; - - this.store.updateState({ usedNetworks }); - } - - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - /** - * Set the interactive replacement token with a url and the old refresh token - * - * @param {object} opts - * @param opts.url - * @param opts.oldRefreshToken - * @returns {void} - */ - showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) { - this.store.updateState({ - interactiveReplacementToken: { - url, - oldRefreshToken, - }, - }); - } - - /** - * Set the setCustodianDeepLink with the fromAddress and custodyId - * - * @param {object} opts - * @param opts.fromAddress - * @param opts.custodyId - * @returns {void} - */ - setCustodianDeepLink({ fromAddress, custodyId }) { - this.store.updateState({ - custodianDeepLink: { fromAddress, custodyId }, - }); - } - - setNoteToTraderMessage(message) { - this.store.updateState({ - noteToTraderMessage: message, - }); - } - - ///: END:ONLY_INCLUDE_IF - - getSignatureSecurityAlertResponse(securityAlertId) { - return this.store.getState().signatureSecurityAlertResponses[ - securityAlertId - ]; - } - - addSignatureSecurityAlertResponse(securityAlertResponse) { - const currentState = this.store.getState(); - const { signatureSecurityAlertResponses } = currentState; - this.store.updateState({ - signatureSecurityAlertResponses: { - ...signatureSecurityAlertResponses, - [securityAlertResponse.securityAlertId]: securityAlertResponse, - }, - }); - } - - /** - * A setter for the currentPopupId which indicates the id of popup window that's currently active - * - * @param currentPopupId - */ - setCurrentPopupId(currentPopupId) { - this.store.updateState({ - currentPopupId, - }); - } - - /** - * The function returns information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. - */ - getLastInteractedConfirmationInfo() { - return this.store.getState().lastInteractedConfirmationInfo; - } - - /** - * Update the information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. - */ - setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { - this.store.updateState({ - lastInteractedConfirmationInfo, - }); - } - - /** - * A getter to retrieve currentPopupId saved in the appState - */ - getCurrentPopupId() { - return this.store.getState().currentPopupId; - } - - _requestApproval() { - // If we already have a pending request this is a no-op - if (this._approvalRequestId) { - return; - } - this._approvalRequestId = uuid(); - - this.messagingSystem - .call( - 'ApprovalController:addRequest', - { - id: this._approvalRequestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.Unlock, - }, - true, - ) - .catch(() => { - // If the promise fails, we allow a new popup to be triggered - this._approvalRequestId = null; - }); - } - - _acceptApproval() { - if (!this._approvalRequestId) { - return; - } - try { - this.messagingSystem.call( - 'ApprovalController:acceptRequest', - this._approvalRequestId, - ); - } catch (error) { - log.error('Failed to unlock approval request', error); - } - - this._approvalRequestId = null; - } -} From 03b21bb0536851b6fb391565a4700e0e9ec02ae7 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 13:22:15 +0100 Subject: [PATCH 09/24] app-state.js deleted --- app/scripts/controllers/app-state.js | 651 --------------------------- 1 file changed, 651 deletions(-) delete mode 100644 app/scripts/controllers/app-state.js diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js deleted file mode 100644 index 9dabf2313e5..00000000000 --- a/app/scripts/controllers/app-state.js +++ /dev/null @@ -1,651 +0,0 @@ -import EventEmitter from 'events'; -import { ObservableStore } from '@metamask/obs-store'; -import { v4 as uuid } from 'uuid'; -import log from 'loglevel'; -import { ApprovalType } from '@metamask/controller-utils'; -import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; -import { MINUTE } from '../../../shared/constants/time'; -import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; -import { isManifestV3 } from '../../../shared/modules/mv3.utils'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { isBeta } from '../../../ui/helpers/utils/build-types'; -import { - ENVIRONMENT_TYPE_BACKGROUND, - POLLING_TOKEN_ENVIRONMENT_TYPES, - ORIGIN_METAMASK, -} from '../../../shared/constants/app'; -import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; - -/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ - -export default class AppStateController extends EventEmitter { - /** - * @param {object} opts - */ - constructor(opts = {}) { - const { - addUnlockListener, - isUnlocked, - initState, - onInactiveTimeout, - preferencesController, - messenger, - extension, - } = opts; - super(); - - this.extension = extension; - this.onInactiveTimeout = onInactiveTimeout || (() => undefined); - this.store = new ObservableStore({ - timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, - connectedStatusPopoverHasBeenShown: true, - defaultHomeActiveTabName: null, - browserEnvironment: {}, - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - recoveryPhraseReminderHasBeenShown: false, - recoveryPhraseReminderLastShown: new Date().getTime(), - outdatedBrowserWarningLastShown: null, - nftsDetectionNoticeDismissed: false, - showTestnetMessageInDropdown: true, - showBetaHeader: isBeta(), - showPermissionsTour: true, - showNetworkBanner: true, - showAccountBanner: true, - trezorModel: null, - currentPopupId: undefined, - onboardingDate: null, - lastViewedUserSurvey: null, - newPrivacyPolicyToastClickedOrClosed: null, - newPrivacyPolicyToastShownDate: null, - // This key is only used for checking if the user had set advancedGasFee - // prior to Migration 92.3 where we split out the setting to support - // multiple networks. - hadAdvancedGasFeesSetPriorToMigration92_3: false, - ...initState, - qrHardware: {}, - nftsDropdownState: {}, - usedNetworks: { - '0x1': true, - '0x5': true, - '0x539': true, - }, - surveyLinkLastClickedOrClosed: null, - signatureSecurityAlertResponses: {}, - // States used for displaying the changed network toast - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage: false, - currentExtensionPopupId: 0, - lastInteractedConfirmationInfo: undefined, - }); - this.timer = null; - - this.isUnlocked = isUnlocked; - this.waitingForUnlock = []; - addUnlockListener(this.handleUnlock.bind(this)); - - messenger.subscribe( - 'PreferencesController:stateChange', - ({ preferences }) => { - const currentState = this.store.getState(); - if ( - preferences && - currentState.timeoutMinutes !== preferences.autoLockTimeLimit - ) { - this._setInactiveTimeout(preferences.autoLockTimeLimit); - } - }, - ); - - messenger.subscribe( - 'KeyringController:qrKeyringStateChange', - (qrHardware) => - this.store.updateState({ - qrHardware, - }), - ); - - const { preferences } = preferencesController.state; - - this._setInactiveTimeout(preferences.autoLockTimeLimit); - - this.messagingSystem = messenger; - this._approvalRequestId = null; - } - - /** - * Get a Promise that resolves when the extension is unlocked. - * This Promise will never reject. - * - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - * @returns {Promise} A promise that resolves when the extension is - * unlocked, or immediately if the extension is already unlocked. - */ - getUnlockPromise(shouldShowUnlockRequest) { - return new Promise((resolve) => { - if (this.isUnlocked()) { - resolve(); - } else { - this.waitForUnlock(resolve, shouldShowUnlockRequest); - } - }); - } - - /** - * Adds a Promise's resolve function to the waitingForUnlock queue. - * Also opens the extension popup if specified. - * - * @param {Promise.resolve} resolve - A Promise's resolve function that will - * be called when the extension is unlocked. - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - */ - waitForUnlock(resolve, shouldShowUnlockRequest) { - this.waitingForUnlock.push({ resolve }); - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - if (shouldShowUnlockRequest) { - this._requestApproval(); - } - } - - /** - * Drains the waitingForUnlock queue, resolving all the related Promises. - */ - handleUnlock() { - if (this.waitingForUnlock.length > 0) { - while (this.waitingForUnlock.length > 0) { - this.waitingForUnlock.shift().resolve(); - } - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - } - - this._acceptApproval(); - } - - /** - * Sets the default home tab - * - * @param {string} [defaultHomeActiveTabName] - the tab name - */ - setDefaultHomeActiveTabName(defaultHomeActiveTabName) { - this.store.updateState({ - defaultHomeActiveTabName, - }); - } - - /** - * Record that the user has seen the connected status info popover - */ - setConnectedStatusPopoverHasBeenShown() { - this.store.updateState({ - connectedStatusPopoverHasBeenShown: true, - }); - } - - /** - * Record that the user has been shown the recovery phrase reminder. - */ - setRecoveryPhraseReminderHasBeenShown() { - this.store.updateState({ - recoveryPhraseReminderHasBeenShown: true, - }); - } - - setSurveyLinkLastClickedOrClosed(time) { - this.store.updateState({ - surveyLinkLastClickedOrClosed: time, - }); - } - - setOnboardingDate() { - this.store.updateState({ - onboardingDate: Date.now(), - }); - } - - setLastViewedUserSurvey(id) { - this.store.updateState({ - lastViewedUserSurvey: id, - }); - } - - setNewPrivacyPolicyToastClickedOrClosed() { - this.store.updateState({ - newPrivacyPolicyToastClickedOrClosed: true, - }); - } - - setNewPrivacyPolicyToastShownDate(time) { - this.store.updateState({ - newPrivacyPolicyToastShownDate: time, - }); - } - - /** - * Record the timestamp of the last time the user has seen the recovery phrase reminder - * - * @param {number} lastShown - timestamp when user was last shown the reminder. - */ - setRecoveryPhraseReminderLastShown(lastShown) { - this.store.updateState({ - recoveryPhraseReminderLastShown: lastShown, - }); - } - - /** - * Record the timestamp of the last time the user has acceoted the terms of use - * - * @param {number} lastAgreed - timestamp when user last accepted the terms of use - */ - setTermsOfUseLastAgreed(lastAgreed) { - this.store.updateState({ - termsOfUseLastAgreed: lastAgreed, - }); - } - - /** - * Record if popover for snaps privacy warning has been shown - * on the first install of a snap. - * - * @param {boolean} shown - shown status - */ - setSnapsInstallPrivacyWarningShownStatus(shown) { - this.store.updateState({ - snapsInstallPrivacyWarningShown: shown, - }); - } - - /** - * Record the timestamp of the last time the user has seen the outdated browser warning - * - * @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. - */ - setOutdatedBrowserWarningLastShown(lastShown) { - this.store.updateState({ - outdatedBrowserWarningLastShown: lastShown, - }); - } - - /** - * Sets the last active time to the current time. - */ - setLastActiveTime() { - this._resetTimer(); - } - - /** - * Sets the inactive timeout for the app - * - * @private - * @param {number} timeoutMinutes - The inactive timeout in minutes. - */ - _setInactiveTimeout(timeoutMinutes) { - this.store.updateState({ - timeoutMinutes, - }); - - this._resetTimer(); - } - - /** - * Resets the internal inactive timer - * - * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new - * timer will not be created. - * - * @private - */ - /* eslint-disable no-undef */ - _resetTimer() { - const { timeoutMinutes } = this.store.getState(); - - if (this.timer) { - clearTimeout(this.timer); - } else if (isManifestV3) { - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - - if (!timeoutMinutes) { - return; - } - - // This is a temporary fix until we add a state migration. - // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, - // it was possible for timeoutMinutes to be saved as a string, as explained - // in PR 25109. `alarms.create` will fail in that case. We are - // converting this to a number here to prevent that failure. Once - // we add a migration to update the malformed state to the right type, - // we will remove this conversion. - const timeoutToSet = Number(timeoutMinutes); - - if (isManifestV3) { - this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { - delayInMinutes: timeoutToSet, - periodInMinutes: timeoutToSet, - }); - this.extension.alarms.onAlarm.addListener((alarmInfo) => { - if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - }); - } else { - this.timer = setTimeout( - () => this.onInactiveTimeout(), - timeoutToSet * MINUTE, - ); - } - } - - /** - * Sets the current browser and OS environment - * - * @param os - * @param browser - */ - setBrowserEnvironment(os, browser) { - this.store.updateState({ browserEnvironment: { os, browser } }); - } - - /** - * Adds a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - addPollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: [...prevState, pollingToken], - }); - } - } - - /** - * removes a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - removePollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: prevState.filter((token) => token !== pollingToken), - }); - } - } - - /** - * clears all pollingTokens - */ - clearPollingTokens() { - this.store.updateState({ - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - }); - } - - /** - * Sets whether the testnet dismissal link should be shown in the network dropdown - * - * @param showTestnetMessageInDropdown - */ - setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { - this.store.updateState({ showTestnetMessageInDropdown }); - } - - /** - * Sets whether the beta notification heading on the home page - * - * @param showBetaHeader - */ - setShowBetaHeader(showBetaHeader) { - this.store.updateState({ showBetaHeader }); - } - - /** - * Sets whether the permissions tour should be shown to the user - * - * @param showPermissionsTour - */ - setShowPermissionsTour(showPermissionsTour) { - this.store.updateState({ showPermissionsTour }); - } - - /** - * Sets whether the Network Banner should be shown - * - * @param showNetworkBanner - */ - setShowNetworkBanner(showNetworkBanner) { - this.store.updateState({ showNetworkBanner }); - } - - /** - * Sets whether the Account Banner should be shown - * - * @param showAccountBanner - */ - setShowAccountBanner(showAccountBanner) { - this.store.updateState({ showAccountBanner }); - } - - /** - * Sets a unique ID for the current extension popup - * - * @param currentExtensionPopupId - */ - setCurrentExtensionPopupId(currentExtensionPopupId) { - this.store.updateState({ currentExtensionPopupId }); - } - - /** - * Sets an object with networkName and appName - * or `null` if the message is meant to be cleared - * - * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. - */ - setSwitchedNetworkDetails(switchedNetworkDetails) { - this.store.updateState({ switchedNetworkDetails }); - } - - /** - * Clears the switched network details in state - */ - clearSwitchedNetworkDetails() { - this.store.updateState({ switchedNetworkDetails: null }); - } - - /** - * Remembers if the user prefers to never see the - * network switched message again - * - * @param {boolean} switchedNetworkNeverShowMessage - */ - setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { - this.store.updateState({ - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage, - }); - } - - /** - * Sets a property indicating the model of the user's Trezor hardware wallet - * - * @param trezorModel - The Trezor model. - */ - setTrezorModel(trezorModel) { - this.store.updateState({ trezorModel }); - } - - /** - * A setter for the `nftsDropdownState` property - * - * @param nftsDropdownState - */ - updateNftDropDownState(nftsDropdownState) { - this.store.updateState({ - nftsDropdownState, - }); - } - - /** - * Updates the array of the first time used networks - * - * @param chainId - * @returns {void} - */ - setFirstTimeUsedNetwork(chainId) { - const currentState = this.store.getState(); - const { usedNetworks } = currentState; - usedNetworks[chainId] = true; - - this.store.updateState({ usedNetworks }); - } - - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - /** - * Set the interactive replacement token with a url and the old refresh token - * - * @param {object} opts - * @param opts.url - * @param opts.oldRefreshToken - * @returns {void} - */ - showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) { - this.store.updateState({ - interactiveReplacementToken: { - url, - oldRefreshToken, - }, - }); - } - - /** - * Set the setCustodianDeepLink with the fromAddress and custodyId - * - * @param {object} opts - * @param opts.fromAddress - * @param opts.custodyId - * @returns {void} - */ - setCustodianDeepLink({ fromAddress, custodyId }) { - this.store.updateState({ - custodianDeepLink: { fromAddress, custodyId }, - }); - } - - setNoteToTraderMessage(message) { - this.store.updateState({ - noteToTraderMessage: message, - }); - } - - ///: END:ONLY_INCLUDE_IF - - getSignatureSecurityAlertResponse(securityAlertId) { - return this.store.getState().signatureSecurityAlertResponses[ - securityAlertId - ]; - } - - addSignatureSecurityAlertResponse(securityAlertResponse) { - const currentState = this.store.getState(); - const { signatureSecurityAlertResponses } = currentState; - this.store.updateState({ - signatureSecurityAlertResponses: { - ...signatureSecurityAlertResponses, - [securityAlertResponse.securityAlertId]: securityAlertResponse, - }, - }); - } - - /** - * A setter for the currentPopupId which indicates the id of popup window that's currently active - * - * @param currentPopupId - */ - setCurrentPopupId(currentPopupId) { - this.store.updateState({ - currentPopupId, - }); - } - - /** - * The function returns information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. - */ - getLastInteractedConfirmationInfo() { - return this.store.getState().lastInteractedConfirmationInfo; - } - - /** - * Update the information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. - */ - setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { - this.store.updateState({ - lastInteractedConfirmationInfo, - }); - } - - /** - * A getter to retrieve currentPopupId saved in the appState - */ - getCurrentPopupId() { - return this.store.getState().currentPopupId; - } - - _requestApproval() { - // If we already have a pending request this is a no-op - if (this._approvalRequestId) { - return; - } - this._approvalRequestId = uuid(); - - this.messagingSystem - .call( - 'ApprovalController:addRequest', - { - id: this._approvalRequestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.Unlock, - }, - true, - ) - .catch(() => { - // If the promise fails, we allow a new popup to be triggered - this._approvalRequestId = null; - }); - } - - _acceptApproval() { - if (!this._approvalRequestId) { - return; - } - try { - this.messagingSystem.call( - 'ApprovalController:acceptRequest', - this._approvalRequestId, - ); - } catch (error) { - log.error('Failed to unlock approval request', error); - } - - this._approvalRequestId = null; - } -} From a3487fe4c1abaf504e740fa3e8ddbe2e51a9ebee Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 13:40:22 +0100 Subject: [PATCH 10/24] replace app-state with app-state-controller --- app/scripts/controllers/app-state.js | 651 +++++++++++++++++++++++++++ 1 file changed, 651 insertions(+) create mode 100644 app/scripts/controllers/app-state.js diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js new file mode 100644 index 00000000000..9dabf2313e5 --- /dev/null +++ b/app/scripts/controllers/app-state.js @@ -0,0 +1,651 @@ +import EventEmitter from 'events'; +import { ObservableStore } from '@metamask/obs-store'; +import { v4 as uuid } from 'uuid'; +import log from 'loglevel'; +import { ApprovalType } from '@metamask/controller-utils'; +import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; +import { MINUTE } from '../../../shared/constants/time'; +import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; +import { isManifestV3 } from '../../../shared/modules/mv3.utils'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { isBeta } from '../../../ui/helpers/utils/build-types'; +import { + ENVIRONMENT_TYPE_BACKGROUND, + POLLING_TOKEN_ENVIRONMENT_TYPES, + ORIGIN_METAMASK, +} from '../../../shared/constants/app'; +import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; + +/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ + +export default class AppStateController extends EventEmitter { + /** + * @param {object} opts + */ + constructor(opts = {}) { + const { + addUnlockListener, + isUnlocked, + initState, + onInactiveTimeout, + preferencesController, + messenger, + extension, + } = opts; + super(); + + this.extension = extension; + this.onInactiveTimeout = onInactiveTimeout || (() => undefined); + this.store = new ObservableStore({ + timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, + connectedStatusPopoverHasBeenShown: true, + defaultHomeActiveTabName: null, + browserEnvironment: {}, + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: new Date().getTime(), + outdatedBrowserWarningLastShown: null, + nftsDetectionNoticeDismissed: false, + showTestnetMessageInDropdown: true, + showBetaHeader: isBeta(), + showPermissionsTour: true, + showNetworkBanner: true, + showAccountBanner: true, + trezorModel: null, + currentPopupId: undefined, + onboardingDate: null, + lastViewedUserSurvey: null, + newPrivacyPolicyToastClickedOrClosed: null, + newPrivacyPolicyToastShownDate: null, + // This key is only used for checking if the user had set advancedGasFee + // prior to Migration 92.3 where we split out the setting to support + // multiple networks. + hadAdvancedGasFeesSetPriorToMigration92_3: false, + ...initState, + qrHardware: {}, + nftsDropdownState: {}, + usedNetworks: { + '0x1': true, + '0x5': true, + '0x539': true, + }, + surveyLinkLastClickedOrClosed: null, + signatureSecurityAlertResponses: {}, + // States used for displaying the changed network toast + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage: false, + currentExtensionPopupId: 0, + lastInteractedConfirmationInfo: undefined, + }); + this.timer = null; + + this.isUnlocked = isUnlocked; + this.waitingForUnlock = []; + addUnlockListener(this.handleUnlock.bind(this)); + + messenger.subscribe( + 'PreferencesController:stateChange', + ({ preferences }) => { + const currentState = this.store.getState(); + if ( + preferences && + currentState.timeoutMinutes !== preferences.autoLockTimeLimit + ) { + this._setInactiveTimeout(preferences.autoLockTimeLimit); + } + }, + ); + + messenger.subscribe( + 'KeyringController:qrKeyringStateChange', + (qrHardware) => + this.store.updateState({ + qrHardware, + }), + ); + + const { preferences } = preferencesController.state; + + this._setInactiveTimeout(preferences.autoLockTimeLimit); + + this.messagingSystem = messenger; + this._approvalRequestId = null; + } + + /** + * Get a Promise that resolves when the extension is unlocked. + * This Promise will never reject. + * + * @param {boolean} shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + * @returns {Promise} A promise that resolves when the extension is + * unlocked, or immediately if the extension is already unlocked. + */ + getUnlockPromise(shouldShowUnlockRequest) { + return new Promise((resolve) => { + if (this.isUnlocked()) { + resolve(); + } else { + this.waitForUnlock(resolve, shouldShowUnlockRequest); + } + }); + } + + /** + * Adds a Promise's resolve function to the waitingForUnlock queue. + * Also opens the extension popup if specified. + * + * @param {Promise.resolve} resolve - A Promise's resolve function that will + * be called when the extension is unlocked. + * @param {boolean} shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + */ + waitForUnlock(resolve, shouldShowUnlockRequest) { + this.waitingForUnlock.push({ resolve }); + this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); + if (shouldShowUnlockRequest) { + this._requestApproval(); + } + } + + /** + * Drains the waitingForUnlock queue, resolving all the related Promises. + */ + handleUnlock() { + if (this.waitingForUnlock.length > 0) { + while (this.waitingForUnlock.length > 0) { + this.waitingForUnlock.shift().resolve(); + } + this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); + } + + this._acceptApproval(); + } + + /** + * Sets the default home tab + * + * @param {string} [defaultHomeActiveTabName] - the tab name + */ + setDefaultHomeActiveTabName(defaultHomeActiveTabName) { + this.store.updateState({ + defaultHomeActiveTabName, + }); + } + + /** + * Record that the user has seen the connected status info popover + */ + setConnectedStatusPopoverHasBeenShown() { + this.store.updateState({ + connectedStatusPopoverHasBeenShown: true, + }); + } + + /** + * Record that the user has been shown the recovery phrase reminder. + */ + setRecoveryPhraseReminderHasBeenShown() { + this.store.updateState({ + recoveryPhraseReminderHasBeenShown: true, + }); + } + + setSurveyLinkLastClickedOrClosed(time) { + this.store.updateState({ + surveyLinkLastClickedOrClosed: time, + }); + } + + setOnboardingDate() { + this.store.updateState({ + onboardingDate: Date.now(), + }); + } + + setLastViewedUserSurvey(id) { + this.store.updateState({ + lastViewedUserSurvey: id, + }); + } + + setNewPrivacyPolicyToastClickedOrClosed() { + this.store.updateState({ + newPrivacyPolicyToastClickedOrClosed: true, + }); + } + + setNewPrivacyPolicyToastShownDate(time) { + this.store.updateState({ + newPrivacyPolicyToastShownDate: time, + }); + } + + /** + * Record the timestamp of the last time the user has seen the recovery phrase reminder + * + * @param {number} lastShown - timestamp when user was last shown the reminder. + */ + setRecoveryPhraseReminderLastShown(lastShown) { + this.store.updateState({ + recoveryPhraseReminderLastShown: lastShown, + }); + } + + /** + * Record the timestamp of the last time the user has acceoted the terms of use + * + * @param {number} lastAgreed - timestamp when user last accepted the terms of use + */ + setTermsOfUseLastAgreed(lastAgreed) { + this.store.updateState({ + termsOfUseLastAgreed: lastAgreed, + }); + } + + /** + * Record if popover for snaps privacy warning has been shown + * on the first install of a snap. + * + * @param {boolean} shown - shown status + */ + setSnapsInstallPrivacyWarningShownStatus(shown) { + this.store.updateState({ + snapsInstallPrivacyWarningShown: shown, + }); + } + + /** + * Record the timestamp of the last time the user has seen the outdated browser warning + * + * @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. + */ + setOutdatedBrowserWarningLastShown(lastShown) { + this.store.updateState({ + outdatedBrowserWarningLastShown: lastShown, + }); + } + + /** + * Sets the last active time to the current time. + */ + setLastActiveTime() { + this._resetTimer(); + } + + /** + * Sets the inactive timeout for the app + * + * @private + * @param {number} timeoutMinutes - The inactive timeout in minutes. + */ + _setInactiveTimeout(timeoutMinutes) { + this.store.updateState({ + timeoutMinutes, + }); + + this._resetTimer(); + } + + /** + * Resets the internal inactive timer + * + * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new + * timer will not be created. + * + * @private + */ + /* eslint-disable no-undef */ + _resetTimer() { + const { timeoutMinutes } = this.store.getState(); + + if (this.timer) { + clearTimeout(this.timer); + } else if (isManifestV3) { + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + + if (!timeoutMinutes) { + return; + } + + // This is a temporary fix until we add a state migration. + // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, + // it was possible for timeoutMinutes to be saved as a string, as explained + // in PR 25109. `alarms.create` will fail in that case. We are + // converting this to a number here to prevent that failure. Once + // we add a migration to update the malformed state to the right type, + // we will remove this conversion. + const timeoutToSet = Number(timeoutMinutes); + + if (isManifestV3) { + this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { + delayInMinutes: timeoutToSet, + periodInMinutes: timeoutToSet, + }); + this.extension.alarms.onAlarm.addListener((alarmInfo) => { + if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { + this.onInactiveTimeout(); + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + }); + } else { + this.timer = setTimeout( + () => this.onInactiveTimeout(), + timeoutToSet * MINUTE, + ); + } + } + + /** + * Sets the current browser and OS environment + * + * @param os + * @param browser + */ + setBrowserEnvironment(os, browser) { + this.store.updateState({ browserEnvironment: { os, browser } }); + } + + /** + * Adds a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + addPollingToken(pollingToken, pollingTokenType) { + if ( + pollingTokenType !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + const prevState = this.store.getState()[pollingTokenType]; + this.store.updateState({ + [pollingTokenType]: [...prevState, pollingToken], + }); + } + } + + /** + * removes a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + removePollingToken(pollingToken, pollingTokenType) { + if ( + pollingTokenType !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + const prevState = this.store.getState()[pollingTokenType]; + this.store.updateState({ + [pollingTokenType]: prevState.filter((token) => token !== pollingToken), + }); + } + } + + /** + * clears all pollingTokens + */ + clearPollingTokens() { + this.store.updateState({ + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + }); + } + + /** + * Sets whether the testnet dismissal link should be shown in the network dropdown + * + * @param showTestnetMessageInDropdown + */ + setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { + this.store.updateState({ showTestnetMessageInDropdown }); + } + + /** + * Sets whether the beta notification heading on the home page + * + * @param showBetaHeader + */ + setShowBetaHeader(showBetaHeader) { + this.store.updateState({ showBetaHeader }); + } + + /** + * Sets whether the permissions tour should be shown to the user + * + * @param showPermissionsTour + */ + setShowPermissionsTour(showPermissionsTour) { + this.store.updateState({ showPermissionsTour }); + } + + /** + * Sets whether the Network Banner should be shown + * + * @param showNetworkBanner + */ + setShowNetworkBanner(showNetworkBanner) { + this.store.updateState({ showNetworkBanner }); + } + + /** + * Sets whether the Account Banner should be shown + * + * @param showAccountBanner + */ + setShowAccountBanner(showAccountBanner) { + this.store.updateState({ showAccountBanner }); + } + + /** + * Sets a unique ID for the current extension popup + * + * @param currentExtensionPopupId + */ + setCurrentExtensionPopupId(currentExtensionPopupId) { + this.store.updateState({ currentExtensionPopupId }); + } + + /** + * Sets an object with networkName and appName + * or `null` if the message is meant to be cleared + * + * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. + */ + setSwitchedNetworkDetails(switchedNetworkDetails) { + this.store.updateState({ switchedNetworkDetails }); + } + + /** + * Clears the switched network details in state + */ + clearSwitchedNetworkDetails() { + this.store.updateState({ switchedNetworkDetails: null }); + } + + /** + * Remembers if the user prefers to never see the + * network switched message again + * + * @param {boolean} switchedNetworkNeverShowMessage + */ + setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { + this.store.updateState({ + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage, + }); + } + + /** + * Sets a property indicating the model of the user's Trezor hardware wallet + * + * @param trezorModel - The Trezor model. + */ + setTrezorModel(trezorModel) { + this.store.updateState({ trezorModel }); + } + + /** + * A setter for the `nftsDropdownState` property + * + * @param nftsDropdownState + */ + updateNftDropDownState(nftsDropdownState) { + this.store.updateState({ + nftsDropdownState, + }); + } + + /** + * Updates the array of the first time used networks + * + * @param chainId + * @returns {void} + */ + setFirstTimeUsedNetwork(chainId) { + const currentState = this.store.getState(); + const { usedNetworks } = currentState; + usedNetworks[chainId] = true; + + this.store.updateState({ usedNetworks }); + } + + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + /** + * Set the interactive replacement token with a url and the old refresh token + * + * @param {object} opts + * @param opts.url + * @param opts.oldRefreshToken + * @returns {void} + */ + showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) { + this.store.updateState({ + interactiveReplacementToken: { + url, + oldRefreshToken, + }, + }); + } + + /** + * Set the setCustodianDeepLink with the fromAddress and custodyId + * + * @param {object} opts + * @param opts.fromAddress + * @param opts.custodyId + * @returns {void} + */ + setCustodianDeepLink({ fromAddress, custodyId }) { + this.store.updateState({ + custodianDeepLink: { fromAddress, custodyId }, + }); + } + + setNoteToTraderMessage(message) { + this.store.updateState({ + noteToTraderMessage: message, + }); + } + + ///: END:ONLY_INCLUDE_IF + + getSignatureSecurityAlertResponse(securityAlertId) { + return this.store.getState().signatureSecurityAlertResponses[ + securityAlertId + ]; + } + + addSignatureSecurityAlertResponse(securityAlertResponse) { + const currentState = this.store.getState(); + const { signatureSecurityAlertResponses } = currentState; + this.store.updateState({ + signatureSecurityAlertResponses: { + ...signatureSecurityAlertResponses, + [securityAlertResponse.securityAlertId]: securityAlertResponse, + }, + }); + } + + /** + * A setter for the currentPopupId which indicates the id of popup window that's currently active + * + * @param currentPopupId + */ + setCurrentPopupId(currentPopupId) { + this.store.updateState({ + currentPopupId, + }); + } + + /** + * The function returns information about the last confirmation user interacted with + * + * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. + */ + getLastInteractedConfirmationInfo() { + return this.store.getState().lastInteractedConfirmationInfo; + } + + /** + * Update the information about the last confirmation user interacted with + * + * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. + */ + setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { + this.store.updateState({ + lastInteractedConfirmationInfo, + }); + } + + /** + * A getter to retrieve currentPopupId saved in the appState + */ + getCurrentPopupId() { + return this.store.getState().currentPopupId; + } + + _requestApproval() { + // If we already have a pending request this is a no-op + if (this._approvalRequestId) { + return; + } + this._approvalRequestId = uuid(); + + this.messagingSystem + .call( + 'ApprovalController:addRequest', + { + id: this._approvalRequestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.Unlock, + }, + true, + ) + .catch(() => { + // If the promise fails, we allow a new popup to be triggered + this._approvalRequestId = null; + }); + } + + _acceptApproval() { + if (!this._approvalRequestId) { + return; + } + try { + this.messagingSystem.call( + 'ApprovalController:acceptRequest', + this._approvalRequestId, + ); + } catch (error) { + log.error('Failed to unlock approval request', error); + } + + this._approvalRequestId = null; + } +} From 25b3ec51f7d7d278335223b623aceabc589d4c13 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 13:43:02 +0100 Subject: [PATCH 11/24] replace app-state with app-state-controller --- app/scripts/controllers/app-state.js | 651 --------------------------- 1 file changed, 651 deletions(-) delete mode 100644 app/scripts/controllers/app-state.js diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js deleted file mode 100644 index 9dabf2313e5..00000000000 --- a/app/scripts/controllers/app-state.js +++ /dev/null @@ -1,651 +0,0 @@ -import EventEmitter from 'events'; -import { ObservableStore } from '@metamask/obs-store'; -import { v4 as uuid } from 'uuid'; -import log from 'loglevel'; -import { ApprovalType } from '@metamask/controller-utils'; -import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; -import { MINUTE } from '../../../shared/constants/time'; -import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; -import { isManifestV3 } from '../../../shared/modules/mv3.utils'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { isBeta } from '../../../ui/helpers/utils/build-types'; -import { - ENVIRONMENT_TYPE_BACKGROUND, - POLLING_TOKEN_ENVIRONMENT_TYPES, - ORIGIN_METAMASK, -} from '../../../shared/constants/app'; -import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; - -/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ - -export default class AppStateController extends EventEmitter { - /** - * @param {object} opts - */ - constructor(opts = {}) { - const { - addUnlockListener, - isUnlocked, - initState, - onInactiveTimeout, - preferencesController, - messenger, - extension, - } = opts; - super(); - - this.extension = extension; - this.onInactiveTimeout = onInactiveTimeout || (() => undefined); - this.store = new ObservableStore({ - timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, - connectedStatusPopoverHasBeenShown: true, - defaultHomeActiveTabName: null, - browserEnvironment: {}, - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - recoveryPhraseReminderHasBeenShown: false, - recoveryPhraseReminderLastShown: new Date().getTime(), - outdatedBrowserWarningLastShown: null, - nftsDetectionNoticeDismissed: false, - showTestnetMessageInDropdown: true, - showBetaHeader: isBeta(), - showPermissionsTour: true, - showNetworkBanner: true, - showAccountBanner: true, - trezorModel: null, - currentPopupId: undefined, - onboardingDate: null, - lastViewedUserSurvey: null, - newPrivacyPolicyToastClickedOrClosed: null, - newPrivacyPolicyToastShownDate: null, - // This key is only used for checking if the user had set advancedGasFee - // prior to Migration 92.3 where we split out the setting to support - // multiple networks. - hadAdvancedGasFeesSetPriorToMigration92_3: false, - ...initState, - qrHardware: {}, - nftsDropdownState: {}, - usedNetworks: { - '0x1': true, - '0x5': true, - '0x539': true, - }, - surveyLinkLastClickedOrClosed: null, - signatureSecurityAlertResponses: {}, - // States used for displaying the changed network toast - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage: false, - currentExtensionPopupId: 0, - lastInteractedConfirmationInfo: undefined, - }); - this.timer = null; - - this.isUnlocked = isUnlocked; - this.waitingForUnlock = []; - addUnlockListener(this.handleUnlock.bind(this)); - - messenger.subscribe( - 'PreferencesController:stateChange', - ({ preferences }) => { - const currentState = this.store.getState(); - if ( - preferences && - currentState.timeoutMinutes !== preferences.autoLockTimeLimit - ) { - this._setInactiveTimeout(preferences.autoLockTimeLimit); - } - }, - ); - - messenger.subscribe( - 'KeyringController:qrKeyringStateChange', - (qrHardware) => - this.store.updateState({ - qrHardware, - }), - ); - - const { preferences } = preferencesController.state; - - this._setInactiveTimeout(preferences.autoLockTimeLimit); - - this.messagingSystem = messenger; - this._approvalRequestId = null; - } - - /** - * Get a Promise that resolves when the extension is unlocked. - * This Promise will never reject. - * - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - * @returns {Promise} A promise that resolves when the extension is - * unlocked, or immediately if the extension is already unlocked. - */ - getUnlockPromise(shouldShowUnlockRequest) { - return new Promise((resolve) => { - if (this.isUnlocked()) { - resolve(); - } else { - this.waitForUnlock(resolve, shouldShowUnlockRequest); - } - }); - } - - /** - * Adds a Promise's resolve function to the waitingForUnlock queue. - * Also opens the extension popup if specified. - * - * @param {Promise.resolve} resolve - A Promise's resolve function that will - * be called when the extension is unlocked. - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - */ - waitForUnlock(resolve, shouldShowUnlockRequest) { - this.waitingForUnlock.push({ resolve }); - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - if (shouldShowUnlockRequest) { - this._requestApproval(); - } - } - - /** - * Drains the waitingForUnlock queue, resolving all the related Promises. - */ - handleUnlock() { - if (this.waitingForUnlock.length > 0) { - while (this.waitingForUnlock.length > 0) { - this.waitingForUnlock.shift().resolve(); - } - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - } - - this._acceptApproval(); - } - - /** - * Sets the default home tab - * - * @param {string} [defaultHomeActiveTabName] - the tab name - */ - setDefaultHomeActiveTabName(defaultHomeActiveTabName) { - this.store.updateState({ - defaultHomeActiveTabName, - }); - } - - /** - * Record that the user has seen the connected status info popover - */ - setConnectedStatusPopoverHasBeenShown() { - this.store.updateState({ - connectedStatusPopoverHasBeenShown: true, - }); - } - - /** - * Record that the user has been shown the recovery phrase reminder. - */ - setRecoveryPhraseReminderHasBeenShown() { - this.store.updateState({ - recoveryPhraseReminderHasBeenShown: true, - }); - } - - setSurveyLinkLastClickedOrClosed(time) { - this.store.updateState({ - surveyLinkLastClickedOrClosed: time, - }); - } - - setOnboardingDate() { - this.store.updateState({ - onboardingDate: Date.now(), - }); - } - - setLastViewedUserSurvey(id) { - this.store.updateState({ - lastViewedUserSurvey: id, - }); - } - - setNewPrivacyPolicyToastClickedOrClosed() { - this.store.updateState({ - newPrivacyPolicyToastClickedOrClosed: true, - }); - } - - setNewPrivacyPolicyToastShownDate(time) { - this.store.updateState({ - newPrivacyPolicyToastShownDate: time, - }); - } - - /** - * Record the timestamp of the last time the user has seen the recovery phrase reminder - * - * @param {number} lastShown - timestamp when user was last shown the reminder. - */ - setRecoveryPhraseReminderLastShown(lastShown) { - this.store.updateState({ - recoveryPhraseReminderLastShown: lastShown, - }); - } - - /** - * Record the timestamp of the last time the user has acceoted the terms of use - * - * @param {number} lastAgreed - timestamp when user last accepted the terms of use - */ - setTermsOfUseLastAgreed(lastAgreed) { - this.store.updateState({ - termsOfUseLastAgreed: lastAgreed, - }); - } - - /** - * Record if popover for snaps privacy warning has been shown - * on the first install of a snap. - * - * @param {boolean} shown - shown status - */ - setSnapsInstallPrivacyWarningShownStatus(shown) { - this.store.updateState({ - snapsInstallPrivacyWarningShown: shown, - }); - } - - /** - * Record the timestamp of the last time the user has seen the outdated browser warning - * - * @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. - */ - setOutdatedBrowserWarningLastShown(lastShown) { - this.store.updateState({ - outdatedBrowserWarningLastShown: lastShown, - }); - } - - /** - * Sets the last active time to the current time. - */ - setLastActiveTime() { - this._resetTimer(); - } - - /** - * Sets the inactive timeout for the app - * - * @private - * @param {number} timeoutMinutes - The inactive timeout in minutes. - */ - _setInactiveTimeout(timeoutMinutes) { - this.store.updateState({ - timeoutMinutes, - }); - - this._resetTimer(); - } - - /** - * Resets the internal inactive timer - * - * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new - * timer will not be created. - * - * @private - */ - /* eslint-disable no-undef */ - _resetTimer() { - const { timeoutMinutes } = this.store.getState(); - - if (this.timer) { - clearTimeout(this.timer); - } else if (isManifestV3) { - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - - if (!timeoutMinutes) { - return; - } - - // This is a temporary fix until we add a state migration. - // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, - // it was possible for timeoutMinutes to be saved as a string, as explained - // in PR 25109. `alarms.create` will fail in that case. We are - // converting this to a number here to prevent that failure. Once - // we add a migration to update the malformed state to the right type, - // we will remove this conversion. - const timeoutToSet = Number(timeoutMinutes); - - if (isManifestV3) { - this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { - delayInMinutes: timeoutToSet, - periodInMinutes: timeoutToSet, - }); - this.extension.alarms.onAlarm.addListener((alarmInfo) => { - if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - }); - } else { - this.timer = setTimeout( - () => this.onInactiveTimeout(), - timeoutToSet * MINUTE, - ); - } - } - - /** - * Sets the current browser and OS environment - * - * @param os - * @param browser - */ - setBrowserEnvironment(os, browser) { - this.store.updateState({ browserEnvironment: { os, browser } }); - } - - /** - * Adds a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - addPollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: [...prevState, pollingToken], - }); - } - } - - /** - * removes a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - removePollingToken(pollingToken, pollingTokenType) { - if ( - pollingTokenType !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: prevState.filter((token) => token !== pollingToken), - }); - } - } - - /** - * clears all pollingTokens - */ - clearPollingTokens() { - this.store.updateState({ - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - }); - } - - /** - * Sets whether the testnet dismissal link should be shown in the network dropdown - * - * @param showTestnetMessageInDropdown - */ - setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { - this.store.updateState({ showTestnetMessageInDropdown }); - } - - /** - * Sets whether the beta notification heading on the home page - * - * @param showBetaHeader - */ - setShowBetaHeader(showBetaHeader) { - this.store.updateState({ showBetaHeader }); - } - - /** - * Sets whether the permissions tour should be shown to the user - * - * @param showPermissionsTour - */ - setShowPermissionsTour(showPermissionsTour) { - this.store.updateState({ showPermissionsTour }); - } - - /** - * Sets whether the Network Banner should be shown - * - * @param showNetworkBanner - */ - setShowNetworkBanner(showNetworkBanner) { - this.store.updateState({ showNetworkBanner }); - } - - /** - * Sets whether the Account Banner should be shown - * - * @param showAccountBanner - */ - setShowAccountBanner(showAccountBanner) { - this.store.updateState({ showAccountBanner }); - } - - /** - * Sets a unique ID for the current extension popup - * - * @param currentExtensionPopupId - */ - setCurrentExtensionPopupId(currentExtensionPopupId) { - this.store.updateState({ currentExtensionPopupId }); - } - - /** - * Sets an object with networkName and appName - * or `null` if the message is meant to be cleared - * - * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. - */ - setSwitchedNetworkDetails(switchedNetworkDetails) { - this.store.updateState({ switchedNetworkDetails }); - } - - /** - * Clears the switched network details in state - */ - clearSwitchedNetworkDetails() { - this.store.updateState({ switchedNetworkDetails: null }); - } - - /** - * Remembers if the user prefers to never see the - * network switched message again - * - * @param {boolean} switchedNetworkNeverShowMessage - */ - setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { - this.store.updateState({ - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage, - }); - } - - /** - * Sets a property indicating the model of the user's Trezor hardware wallet - * - * @param trezorModel - The Trezor model. - */ - setTrezorModel(trezorModel) { - this.store.updateState({ trezorModel }); - } - - /** - * A setter for the `nftsDropdownState` property - * - * @param nftsDropdownState - */ - updateNftDropDownState(nftsDropdownState) { - this.store.updateState({ - nftsDropdownState, - }); - } - - /** - * Updates the array of the first time used networks - * - * @param chainId - * @returns {void} - */ - setFirstTimeUsedNetwork(chainId) { - const currentState = this.store.getState(); - const { usedNetworks } = currentState; - usedNetworks[chainId] = true; - - this.store.updateState({ usedNetworks }); - } - - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - /** - * Set the interactive replacement token with a url and the old refresh token - * - * @param {object} opts - * @param opts.url - * @param opts.oldRefreshToken - * @returns {void} - */ - showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) { - this.store.updateState({ - interactiveReplacementToken: { - url, - oldRefreshToken, - }, - }); - } - - /** - * Set the setCustodianDeepLink with the fromAddress and custodyId - * - * @param {object} opts - * @param opts.fromAddress - * @param opts.custodyId - * @returns {void} - */ - setCustodianDeepLink({ fromAddress, custodyId }) { - this.store.updateState({ - custodianDeepLink: { fromAddress, custodyId }, - }); - } - - setNoteToTraderMessage(message) { - this.store.updateState({ - noteToTraderMessage: message, - }); - } - - ///: END:ONLY_INCLUDE_IF - - getSignatureSecurityAlertResponse(securityAlertId) { - return this.store.getState().signatureSecurityAlertResponses[ - securityAlertId - ]; - } - - addSignatureSecurityAlertResponse(securityAlertResponse) { - const currentState = this.store.getState(); - const { signatureSecurityAlertResponses } = currentState; - this.store.updateState({ - signatureSecurityAlertResponses: { - ...signatureSecurityAlertResponses, - [securityAlertResponse.securityAlertId]: securityAlertResponse, - }, - }); - } - - /** - * A setter for the currentPopupId which indicates the id of popup window that's currently active - * - * @param currentPopupId - */ - setCurrentPopupId(currentPopupId) { - this.store.updateState({ - currentPopupId, - }); - } - - /** - * The function returns information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. - */ - getLastInteractedConfirmationInfo() { - return this.store.getState().lastInteractedConfirmationInfo; - } - - /** - * Update the information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. - */ - setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { - this.store.updateState({ - lastInteractedConfirmationInfo, - }); - } - - /** - * A getter to retrieve currentPopupId saved in the appState - */ - getCurrentPopupId() { - return this.store.getState().currentPopupId; - } - - _requestApproval() { - // If we already have a pending request this is a no-op - if (this._approvalRequestId) { - return; - } - this._approvalRequestId = uuid(); - - this.messagingSystem - .call( - 'ApprovalController:addRequest', - { - id: this._approvalRequestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.Unlock, - }, - true, - ) - .catch(() => { - // If the promise fails, we allow a new popup to be triggered - this._approvalRequestId = null; - }); - } - - _acceptApproval() { - if (!this._approvalRequestId) { - return; - } - try { - this.messagingSystem.call( - 'ApprovalController:acceptRequest', - this._approvalRequestId, - ); - } catch (error) { - log.error('Failed to unlock approval request', error); - } - - this._approvalRequestId = null; - } -} From 2e666ec57b0688315a6aa2e49f6e734e0a67820a Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 14:13:22 +0100 Subject: [PATCH 12/24] fix register ApprovalController:addRequest error in tests --- .../controllers/app-state-controller.test.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 655ed12a379..a811178f4cb 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -66,6 +66,12 @@ describe('AppStateController', () => { }, }), ); + controllerMessenger.registerActionHandler( + 'ApprovalController:addRequest', + jest.fn().mockReturnValue({ + catch: jest.fn(), + }), + ); appStateController = new AppStateController({ addUnlockListener: jest.fn(), isUnlocked: jest.fn(() => true), @@ -164,13 +170,6 @@ describe('AppStateController', () => { }); it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { - const addRequestSpy = jest.fn().mockImplementation(() => ({ - catch: jest.fn(), - })); - controllerMessenger.registerActionHandler( - 'ApprovalController:addRequest', - addRequestSpy, - ); createIsUnlockedMock(false); const resolveFn: () => void = jest.fn(); @@ -197,13 +196,6 @@ describe('AppStateController', () => { jest.clearAllMocks(); }); it('accepts approval request revolving all the related promises', async () => { - const addRequestSpy = jest.fn().mockImplementation(() => ({ - catch: jest.fn(), - })); - controllerMessenger.registerActionHandler( - 'ApprovalController:addRequest', - addRequestSpy, - ); const emitSpy = jest.spyOn(appStateController, 'emit'); const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, true); @@ -268,6 +260,24 @@ describe('AppStateController', () => { expect(spy).toHaveBeenCalled(); }); + + it('sets the timer if timeoutMinutes is set', () => { + const timeout = Date.now(); + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + }); }); describe('setBrowserEnvironment', () => { From ec2a092d2c42b7b568563bb37a7f844ae3f4537b Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 15:46:18 +0100 Subject: [PATCH 13/24] add unit tests on isManifestV3 --- .../controllers/app-state-controller.test.ts | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index a811178f4cb..75070f145ed 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -25,6 +25,13 @@ import { jest.mock('webextension-polyfill'); +const mockIsManifestV3 = jest.fn().mockReturnValue(false); +jest.mock('../../../shared/modules/mv3.utils', () => ({ + get isManifestV3() { + return mockIsManifestV3(); + }, +})); + let appStateController: AppStateController; let controllerMessenger: ControllerMessenger< | AppStateControllerActions @@ -37,6 +44,17 @@ let controllerMessenger: ControllerMessenger< | KeyringControllerQRKeyringStateChangeEvent >; +const extensionMock = { + alarms: { + getAll: jest.fn(() => Promise.resolve([])), + create: jest.fn(), + clear: jest.fn(), + onAlarm: { + addListener: jest.fn(), + }, + }, +} as unknown as jest.Mocked; + describe('AppStateController', () => { const createAppStateController = ( initState: Partial = {}, @@ -78,16 +96,7 @@ describe('AppStateController', () => { initState, onInactiveTimeout: jest.fn(), messenger: appStateMessenger, - extension: { - alarms: { - getAll: jest.fn(() => Promise.resolve([])), - create: jest.fn(), - clear: jest.fn(), - onAlarm: { - addListener: jest.fn(), - }, - }, - } as unknown as jest.Mocked, + extension: extensionMock, }); return { appStateController, controllerMessenger }; @@ -637,4 +646,20 @@ describe('AppStateController', () => { ); }); }); + + describe('isManifestV3', () => { + it('creates alarm when isManifestV3 is true', () => { + mockIsManifestV3.mockReturnValue(true); + ({ appStateController } = createAppStateController()); + + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + expect(extensionMock.alarms.clear).toHaveBeenCalled(); + }); +}); }); From 2cc1b4048e00a3a963d8021c81cb113e64e31d13 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 16:19:01 +0100 Subject: [PATCH 14/24] fix tests lint --- .../controllers/app-state-controller.test.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 75070f145ed..d2113bc698b 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -648,18 +648,27 @@ describe('AppStateController', () => { }); describe('isManifestV3', () => { - it('creates alarm when isManifestV3 is true', () => { - mockIsManifestV3.mockReturnValue(true); - ({ appStateController } = createAppStateController()); + it('creates alarm when isManifestV3 is true', () => { + mockIsManifestV3.mockReturnValue(true); + ({ appStateController } = createAppStateController()); - const spy = jest.spyOn( - appStateController as unknown as { _resetTimer: () => void }, - '_resetTimer', - ); - appStateController.setLastActiveTime(); + const timeout = Date.now(); + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); - expect(spy).toHaveBeenCalled(); - expect(extensionMock.alarms.clear).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); + expect(extensionMock.alarms.clear).toHaveBeenCalled(); + expect(extensionMock.alarms.onAlarm.addListener).toHaveBeenCalled(); + }); }); }); -}); From fc242d386099862d85940b323566f9073021ee4e Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 17:01:04 +0100 Subject: [PATCH 15/24] fix autoLockTimeLimit --- app/scripts/controllers/app-state-controller.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 140b6ae753d..8592bfc5dbd 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -239,7 +239,7 @@ export class AppStateController extends EventEmitter { ({ preferences }: { preferences: Partial }) => { const currentState = this.store.getState(); if ( - preferences?.autoLockTimeLimit && + typeof preferences?.autoLockTimeLimit === 'number' && currentState.timeoutMinutes !== preferences.autoLockTimeLimit ) { this._setInactiveTimeout(preferences.autoLockTimeLimit); @@ -256,8 +256,7 @@ export class AppStateController extends EventEmitter { ); const { preferences } = messenger.call('PreferencesController:getState'); - - if (preferences.autoLockTimeLimit) { + if (typeof preferences.autoLockTimeLimit === 'number') { this._setInactiveTimeout(preferences.autoLockTimeLimit); } From a66d394fa888e8f3cefc6901abf8365684c13a98 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Tue, 15 Oct 2024 17:40:24 +0100 Subject: [PATCH 16/24] readonly updated --- app/scripts/controllers/app-state-controller.ts | 8 ++++---- app/scripts/controllers/mmi-controller.ts | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 8592bfc5dbd..c1edec139ef 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -196,9 +196,9 @@ const getDefaultAppStateControllerState = ( }); export class AppStateController extends EventEmitter { - #extension: AppStateControllerOptions['extension']; + readonly #extension: AppStateControllerOptions['extension']; - #onInactiveTimeout: () => void; + readonly #onInactiveTimeout: () => void; store: ObservableStore; @@ -206,9 +206,9 @@ export class AppStateController extends EventEmitter { isUnlocked: () => boolean; - #waitingForUnlock: { resolve: () => void }[]; + readonly #waitingForUnlock: { resolve: () => void }[]; - #messagingSystem: AppStateControllerMessenger; + readonly #messagingSystem: AppStateControllerMessenger; #approvalRequestId: string | null; diff --git a/app/scripts/controllers/mmi-controller.ts b/app/scripts/controllers/mmi-controller.ts index 2253b1abf0c..20f14c47304 100644 --- a/app/scripts/controllers/mmi-controller.ts +++ b/app/scripts/controllers/mmi-controller.ts @@ -44,7 +44,6 @@ import { import { getCurrentChainId } from '../../../ui/selectors'; import MetaMetricsController from './metametrics'; import { getPermissionBackgroundApiMethods } from './permissions'; -import { PreferencesController } from './preferences-controller'; import AccountTrackerController from './account-tracker-controller'; import { AppStateController } from './app-state-controller'; @@ -70,8 +69,6 @@ export default class MMIController extends EventEmitter { // eslint-disable-next-line @typescript-eslint/no-explicit-any public keyringController: any; - public preferencesController: PreferencesController; - public appStateController: AppStateController; public transactionUpdateController: TransactionUpdateController; @@ -142,7 +139,6 @@ export default class MMIController extends EventEmitter { this.messenger = opts.messenger; this.mmiConfigurationController = opts.mmiConfigurationController; this.keyringController = opts.keyringController; - this.preferencesController = opts.preferencesController; this.appStateController = opts.appStateController; this.transactionUpdateController = opts.transactionUpdateController; this.custodyController = opts.custodyController; From 4f42e08272293ea916ee7376fb6de6ede0b03809 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 16 Oct 2024 11:56:32 +0100 Subject: [PATCH 17/24] include review changes and revert method modifier changes --- .../controllers/app-state-controller.ts | 56 +++++++++---------- app/scripts/metamask-controller.js | 1 - 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index c1edec139ef..f422465d035 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -196,19 +196,19 @@ const getDefaultAppStateControllerState = ( }); export class AppStateController extends EventEmitter { - readonly #extension: AppStateControllerOptions['extension']; + private readonly extension: AppStateControllerOptions['extension']; - readonly #onInactiveTimeout: () => void; + private readonly onInactiveTimeout: () => void; store: ObservableStore; - #timer: NodeJS.Timeout | null; + private timer: NodeJS.Timeout | null; isUnlocked: () => boolean; - readonly #waitingForUnlock: { resolve: () => void }[]; + private readonly waitingForUnlock: { resolve: () => void }[]; - readonly #messagingSystem: AppStateControllerMessenger; + private readonly messagingSystem: AppStateControllerMessenger; #approvalRequestId: string | null; @@ -223,15 +223,15 @@ export class AppStateController extends EventEmitter { } = opts; super(); - this.#extension = extension; - this.#onInactiveTimeout = onInactiveTimeout || (() => undefined); + this.extension = extension; + this.onInactiveTimeout = onInactiveTimeout || (() => undefined); this.store = new ObservableStore( getDefaultAppStateControllerState(initState), ); - this.#timer = null; + this.timer = null; this.isUnlocked = isUnlocked; - this.#waitingForUnlock = []; + this.waitingForUnlock = []; addUnlockListener(this.handleUnlock.bind(this)); messenger.subscribe( @@ -260,7 +260,7 @@ export class AppStateController extends EventEmitter { this._setInactiveTimeout(preferences.autoLockTimeLimit); } - this.#messagingSystem = messenger; + this.messagingSystem = messenger; this.#approvalRequestId = null; } @@ -293,7 +293,7 @@ export class AppStateController extends EventEmitter { * popup should be opened. */ waitForUnlock(resolve: () => void, shouldShowUnlockRequest: boolean): void { - this.#waitingForUnlock.push({ resolve }); + this.waitingForUnlock.push({ resolve }); this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); if (shouldShowUnlockRequest) { this._requestApproval(); @@ -304,9 +304,9 @@ export class AppStateController extends EventEmitter { * Drains the waitingForUnlock queue, resolving all the related Promises. */ handleUnlock(): void { - if (this.#waitingForUnlock.length > 0) { - while (this.#waitingForUnlock.length > 0) { - this.#waitingForUnlock.shift()?.resolve(); + if (this.waitingForUnlock.length > 0) { + while (this.waitingForUnlock.length > 0) { + this.waitingForUnlock.shift()?.resolve(); } this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); } @@ -317,7 +317,7 @@ export class AppStateController extends EventEmitter { /** * Sets the default home tab * - * @param [defaultHomeActiveTabName] - the tab name + * @param defaultHomeActiveTabName - the tab name */ setDefaultHomeActiveTabName(defaultHomeActiveTabName: string | null): void { this.store.updateState({ @@ -448,10 +448,10 @@ export class AppStateController extends EventEmitter { private _resetTimer(): void { const { timeoutMinutes } = this.store.getState(); - if (this.#timer) { - clearTimeout(this.#timer); + if (this.timer) { + clearTimeout(this.timer); } else if (isManifestV3) { - this.#extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); } if (!timeoutMinutes) { @@ -468,21 +468,21 @@ export class AppStateController extends EventEmitter { const timeoutToSet = Number(timeoutMinutes); if (isManifestV3) { - this.#extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { + this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { delayInMinutes: timeoutToSet, periodInMinutes: timeoutToSet, }); - this.#extension.alarms.onAlarm.addListener( + this.extension.alarms.onAlarm.addListener( (alarmInfo: { name: string }) => { if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.#onInactiveTimeout(); - this.#extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + this.onInactiveTimeout(); + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); } }, ); } else { - this.#timer = setTimeout( - () => this.#onInactiveTimeout(), + this.timer = setTimeout( + () => this.onInactiveTimeout(), timeoutToSet * MINUTE, ); } @@ -794,8 +794,6 @@ export class AppStateController extends EventEmitter { /** * The function returns information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. */ getLastInteractedConfirmationInfo(): | LastInteractedConfirmationInfo @@ -806,7 +804,7 @@ export class AppStateController extends EventEmitter { /** * Update the information about the last confirmation user interacted with * - * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. + * @param lastInteractedConfirmationInfo */ setLastInteractedConfirmationInfo( lastInteractedConfirmationInfo: LastInteractedConfirmationInfo | undefined, @@ -830,7 +828,7 @@ export class AppStateController extends EventEmitter { } this.#approvalRequestId = uuid(); - this.#messagingSystem + this.messagingSystem .call( 'ApprovalController:addRequest', { @@ -856,7 +854,7 @@ export class AppStateController extends EventEmitter { return; } try { - this.#messagingSystem.call( + this.messagingSystem.call( 'ApprovalController:acceptRequest', this.#approvalRequestId, ); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index b889f5162a2..f9c227bd113 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2013,7 +2013,6 @@ export default class MetamaskController extends EventEmitter { messenger: mmiControllerMessenger, mmiConfigurationController: this.mmiConfigurationController, keyringController: this.keyringController, - preferencesController: this.preferencesController, appStateController: this.appStateController, transactionUpdateController: this.transactionUpdateController, custodyController: this.custodyController, From b48d3c40cbe80cd269d95bca9c29ed15c9a47a46 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 16 Oct 2024 14:07:31 +0100 Subject: [PATCH 18/24] registerHandler for getState and add the unit tests for both action and event --- .../controllers/app-state-controller.test.ts | 45 +++++++++++++++++++ .../controllers/app-state-controller.ts | 4 ++ 2 files changed, 49 insertions(+) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index d2113bc698b..0b8433f5d63 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -671,4 +671,49 @@ describe('AppStateController', () => { expect(extensionMock.alarms.onAlarm.addListener).toHaveBeenCalled(); }); }); + + describe('AppStateController:getState', () => { + it('should return the current state of the property', () => { + expect( + appStateController.store.getState().recoveryPhraseReminderHasBeenShown, + ).toStrictEqual(false); + expect( + controllerMessenger.call('AppStateController:getState') + .recoveryPhraseReminderHasBeenShown, + ).toStrictEqual(false); + }); + }); + + describe('AppStateController:stateChange', () => { + it('subscribers will recieve the state when published', () => { + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(null); + const timeNow = Date.now(); + controllerMessenger.subscribe( + 'AppStateController:stateChange', + (state: Partial) => { + if (typeof state.surveyLinkLastClickedOrClosed === 'number') { + appStateController.setSurveyLinkLastClickedOrClosed( + state.surveyLinkLastClickedOrClosed, + ); + } + }, + ); + controllerMessenger.publish( + 'AppStateController:stateChange', + { + surveyLinkLastClickedOrClosed: timeNow, + } as unknown as AppStateControllerState, + [], + ); + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + expect( + controllerMessenger.call('AppStateController:getState') + .surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + }); + }); }); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index f422465d035..61a4d71fc95 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -261,6 +261,10 @@ export class AppStateController extends EventEmitter { } this.messagingSystem = messenger; + this.messagingSystem.registerActionHandler( + 'AppStateController:getState', + () => this.store.getState(), + ); this.#approvalRequestId = null; } From 9be54989868b4c75516cd34fe83ef1df68907145 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 16 Oct 2024 15:25:44 +0100 Subject: [PATCH 19/24] publish state when there is a change --- .../controllers/app-state-controller.test.ts | 25 +++++++++++++++++++ .../controllers/app-state-controller.ts | 3 +++ 2 files changed, 28 insertions(+) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 0b8433f5d63..3023f07f732 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -700,6 +700,7 @@ describe('AppStateController', () => { } }, ); + controllerMessenger.publish( 'AppStateController:stateChange', { @@ -707,6 +708,30 @@ describe('AppStateController', () => { } as unknown as AppStateControllerState, [], ); + + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + expect( + controllerMessenger.call('AppStateController:getState') + .surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + }); + + it('state will be published when there is state change', () => { + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(null); + const timeNow = Date.now(); + controllerMessenger.subscribe( + 'AppStateController:stateChange', + (state: Partial) => { + expect(state.surveyLinkLastClickedOrClosed).toStrictEqual(timeNow); + }, + ); + + appStateController.setSurveyLinkLastClickedOrClosed(timeNow); + expect( appStateController.store.getState().surveyLinkLastClickedOrClosed, ).toStrictEqual(timeNow); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 61a4d71fc95..e76b8fe3888 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -265,6 +265,9 @@ export class AppStateController extends EventEmitter { 'AppStateController:getState', () => this.store.getState(), ); + this.store.subscribe((state: AppStateControllerState) => { + this.messagingSystem.publish('AppStateController:stateChange', state, []); + }); this.#approvalRequestId = null; } From 1c781044093b71a36c812bb1004e30a3d84067bd Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 16 Oct 2024 16:28:22 +0100 Subject: [PATCH 20/24] to create a git tracking --- .../controllers/app-state-controller.test.ts | 744 --------------- .../controllers/app-state-controller.ts | 874 ------------------ app/scripts/controllers/app-state.js | 651 +++++++++++++ app/scripts/controllers/app-state.test.js | 396 ++++++++ 4 files changed, 1047 insertions(+), 1618 deletions(-) delete mode 100644 app/scripts/controllers/app-state-controller.test.ts delete mode 100644 app/scripts/controllers/app-state-controller.ts create mode 100644 app/scripts/controllers/app-state.js create mode 100644 app/scripts/controllers/app-state.test.js diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts deleted file mode 100644 index 3023f07f732..00000000000 --- a/app/scripts/controllers/app-state-controller.test.ts +++ /dev/null @@ -1,744 +0,0 @@ -import { - AcceptRequest, - AddApprovalRequest, -} from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; -import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; -import { Browser } from 'webextension-polyfill'; -import { - ENVIRONMENT_TYPE_POPUP, - ORIGIN_METAMASK, - POLLING_TOKEN_ENVIRONMENT_TYPES, -} from '../../../shared/constants/app'; -import { AppStateController } from './app-state-controller'; -import type { - AllowedActions, - AllowedEvents, - AppStateControllerActions, - AppStateControllerEvents, - AppStateControllerState, -} from './app-state-controller'; -import { - PreferencesControllerState, - PreferencesControllerStateChangeEvent, -} from './preferences-controller'; - -jest.mock('webextension-polyfill'); - -const mockIsManifestV3 = jest.fn().mockReturnValue(false); -jest.mock('../../../shared/modules/mv3.utils', () => ({ - get isManifestV3() { - return mockIsManifestV3(); - }, -})); - -let appStateController: AppStateController; -let controllerMessenger: ControllerMessenger< - | AppStateControllerActions - | AllowedActions - | AddApprovalRequest - | AcceptRequest, - | AppStateControllerEvents - | AllowedEvents - | PreferencesControllerStateChangeEvent - | KeyringControllerQRKeyringStateChangeEvent ->; - -const extensionMock = { - alarms: { - getAll: jest.fn(() => Promise.resolve([])), - create: jest.fn(), - clear: jest.fn(), - onAlarm: { - addListener: jest.fn(), - }, - }, -} as unknown as jest.Mocked; - -describe('AppStateController', () => { - const createAppStateController = ( - initState: Partial = {}, - ): { - appStateController: AppStateController; - controllerMessenger: typeof controllerMessenger; - } => { - controllerMessenger = new ControllerMessenger(); - jest.spyOn(ControllerMessenger.prototype, 'call'); - const appStateMessenger = controllerMessenger.getRestricted({ - name: 'AppStateController', - allowedActions: [ - `ApprovalController:addRequest`, - `ApprovalController:acceptRequest`, - `PreferencesController:getState`, - ], - allowedEvents: [ - `PreferencesController:stateChange`, - `KeyringController:qrKeyringStateChange`, - ], - }); - controllerMessenger.registerActionHandler( - 'PreferencesController:getState', - jest.fn().mockReturnValue({ - preferences: { - autoLockTimeLimit: 0, - }, - }), - ); - controllerMessenger.registerActionHandler( - 'ApprovalController:addRequest', - jest.fn().mockReturnValue({ - catch: jest.fn(), - }), - ); - appStateController = new AppStateController({ - addUnlockListener: jest.fn(), - isUnlocked: jest.fn(() => true), - initState, - onInactiveTimeout: jest.fn(), - messenger: appStateMessenger, - extension: extensionMock, - }); - - return { appStateController, controllerMessenger }; - }; - - const createIsUnlockedMock = (isUnlocked: boolean) => { - return jest - .spyOn( - appStateController as unknown as { isUnlocked: () => boolean }, - 'isUnlocked', - ) - .mockReturnValue(isUnlocked); - }; - - beforeEach(() => { - ({ appStateController } = createAppStateController()); - }); - - describe('setOutdatedBrowserWarningLastShown', () => { - it('sets the last shown time', () => { - ({ appStateController } = createAppStateController()); - const timestamp: number = Date.now(); - - appStateController.setOutdatedBrowserWarningLastShown(timestamp); - - expect( - appStateController.store.getState().outdatedBrowserWarningLastShown, - ).toStrictEqual(timestamp); - }); - - it('sets outdated browser warning last shown timestamp', () => { - const lastShownTimestamp: number = Date.now(); - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - appStateController.setOutdatedBrowserWarningLastShown(lastShownTimestamp); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - outdatedBrowserWarningLastShown: lastShownTimestamp, - }); - - updateStateSpy.mockRestore(); - }); - }); - - describe('getUnlockPromise', () => { - it('waits for unlock if the extension is locked', async () => { - ({ appStateController } = createAppStateController()); - const isUnlockedMock = createIsUnlockedMock(false); - const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); - - appStateController.getUnlockPromise(true); - expect(isUnlockedMock).toHaveBeenCalled(); - expect(waitForUnlockSpy).toHaveBeenCalledWith(expect.any(Function), true); - }); - - it('resolves immediately if the extension is already unlocked', async () => { - ({ appStateController } = createAppStateController()); - const isUnlockedMock = createIsUnlockedMock(true); - - await expect( - appStateController.getUnlockPromise(false), - ).resolves.toBeUndefined(); - - expect(isUnlockedMock).toHaveBeenCalled(); - }); - }); - - describe('waitForUnlock', () => { - it('resolves immediately if already unlocked', async () => { - const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn: () => void = jest.fn(); - appStateController.waitForUnlock(resolveFn, false); - expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(controllerMessenger.call).toHaveBeenCalledTimes(1); - }); - - it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { - createIsUnlockedMock(false); - - const resolveFn: () => void = jest.fn(); - appStateController.waitForUnlock(resolveFn, true); - - expect(controllerMessenger.call).toHaveBeenCalledTimes(2); - expect(controllerMessenger.call).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - expect.objectContaining({ - id: expect.any(String), - origin: ORIGIN_METAMASK, - type: 'unlock', - }), - true, - ); - }); - }); - - describe('handleUnlock', () => { - beforeEach(() => { - createIsUnlockedMock(false); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('accepts approval request revolving all the related promises', async () => { - const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn: () => void = jest.fn(); - appStateController.waitForUnlock(resolveFn, true); - - appStateController.handleUnlock(); - - expect(emitSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(controllerMessenger.call).toHaveBeenCalled(); - expect(controllerMessenger.call).toHaveBeenCalledWith( - 'ApprovalController:acceptRequest', - expect.any(String), - ); - }); - }); - - describe('setDefaultHomeActiveTabName', () => { - it('sets the default home tab name', () => { - appStateController.setDefaultHomeActiveTabName('testTabName'); - expect(appStateController.store.getState().defaultHomeActiveTabName).toBe( - 'testTabName', - ); - }); - }); - - describe('setConnectedStatusPopoverHasBeenShown', () => { - it('sets connected status popover as shown', () => { - appStateController.setConnectedStatusPopoverHasBeenShown(); - expect( - appStateController.store.getState().connectedStatusPopoverHasBeenShown, - ).toBe(true); - }); - }); - - describe('setRecoveryPhraseReminderHasBeenShown', () => { - it('sets recovery phrase reminder as shown', () => { - appStateController.setRecoveryPhraseReminderHasBeenShown(); - expect( - appStateController.store.getState().recoveryPhraseReminderHasBeenShown, - ).toBe(true); - }); - }); - - describe('setRecoveryPhraseReminderLastShown', () => { - it('sets the last shown time of recovery phrase reminder', () => { - const timestamp: number = Date.now(); - appStateController.setRecoveryPhraseReminderLastShown(timestamp); - - expect( - appStateController.store.getState().recoveryPhraseReminderLastShown, - ).toBe(timestamp); - }); - }); - - describe('setLastActiveTime', () => { - it('sets the last active time to the current time', () => { - const spy = jest.spyOn( - appStateController as unknown as { _resetTimer: () => void }, - '_resetTimer', - ); - appStateController.setLastActiveTime(); - - expect(spy).toHaveBeenCalled(); - }); - - it('sets the timer if timeoutMinutes is set', () => { - const timeout = Date.now(); - controllerMessenger.publish( - 'PreferencesController:stateChange', - { - preferences: { autoLockTimeLimit: timeout }, - } as unknown as PreferencesControllerState, - [], - ); - const spy = jest.spyOn( - appStateController as unknown as { _resetTimer: () => void }, - '_resetTimer', - ); - appStateController.setLastActiveTime(); - - expect(spy).toHaveBeenCalled(); - }); - }); - - describe('setBrowserEnvironment', () => { - it('sets the current browser and OS environment', () => { - appStateController.setBrowserEnvironment('Windows', 'Chrome'); - expect( - appStateController.store.getState().browserEnvironment, - ).toStrictEqual({ - os: 'Windows', - browser: 'Chrome', - }); - }); - }); - - describe('addPollingToken', () => { - it('adds a pollingToken for a given environmentType', () => { - const pollingTokenType = - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; - appStateController.addPollingToken('token1', pollingTokenType); - expect(appStateController.store.getState()[pollingTokenType]).toContain( - 'token1', - ); - }); - }); - - describe('removePollingToken', () => { - it('removes a pollingToken for a given environmentType', () => { - const pollingTokenType = - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; - appStateController.addPollingToken('token1', pollingTokenType); - appStateController.removePollingToken('token1', pollingTokenType); - expect( - appStateController.store.getState()[pollingTokenType], - ).not.toContain('token1'); - }); - }); - - describe('clearPollingTokens', () => { - it('clears all pollingTokens', () => { - appStateController.addPollingToken('token1', 'popupGasPollTokens'); - appStateController.addPollingToken('token2', 'notificationGasPollTokens'); - appStateController.addPollingToken('token3', 'fullScreenGasPollTokens'); - appStateController.clearPollingTokens(); - - expect( - appStateController.store.getState().popupGasPollTokens, - ).toStrictEqual([]); - expect( - appStateController.store.getState().notificationGasPollTokens, - ).toStrictEqual([]); - expect( - appStateController.store.getState().fullScreenGasPollTokens, - ).toStrictEqual([]); - }); - }); - - describe('setShowTestnetMessageInDropdown', () => { - it('sets whether the testnet dismissal link should be shown in the network dropdown', () => { - appStateController.setShowTestnetMessageInDropdown(true); - expect( - appStateController.store.getState().showTestnetMessageInDropdown, - ).toBe(true); - - appStateController.setShowTestnetMessageInDropdown(false); - expect( - appStateController.store.getState().showTestnetMessageInDropdown, - ).toBe(false); - }); - }); - - describe('setShowBetaHeader', () => { - it('sets whether the beta notification heading on the home page', () => { - appStateController.setShowBetaHeader(true); - expect(appStateController.store.getState().showBetaHeader).toBe(true); - - appStateController.setShowBetaHeader(false); - expect(appStateController.store.getState().showBetaHeader).toBe(false); - }); - }); - - describe('setCurrentPopupId', () => { - it('sets the currentPopupId in the appState', () => { - const popupId = 12345; - - appStateController.setCurrentPopupId(popupId); - expect(appStateController.store.getState().currentPopupId).toBe(popupId); - }); - }); - - describe('getCurrentPopupId', () => { - it('retrieves the currentPopupId saved in the appState', () => { - const popupId = 54321; - - appStateController.setCurrentPopupId(popupId); - expect(appStateController.getCurrentPopupId()).toBe(popupId); - }); - }); - - describe('setFirstTimeUsedNetwork', () => { - it('updates the array of the first time used networks', () => { - const chainId = '0x1'; - - appStateController.setFirstTimeUsedNetwork(chainId); - expect(appStateController.store.getState().usedNetworks[chainId]).toBe( - true, - ); - }); - }); - - describe('setLastInteractedConfirmationInfo', () => { - it('sets information about last confirmation user has interacted with', () => { - const lastInteractedConfirmationInfo = { - id: '123', - chainId: '0x1', - timestamp: new Date().getTime(), - }; - appStateController.setLastInteractedConfirmationInfo( - lastInteractedConfirmationInfo, - ); - expect(appStateController.getLastInteractedConfirmationInfo()).toBe( - lastInteractedConfirmationInfo, - ); - - appStateController.setLastInteractedConfirmationInfo(undefined); - expect(appStateController.getLastInteractedConfirmationInfo()).toBe( - undefined, - ); - }); - }); - - describe('setSnapsInstallPrivacyWarningShownStatus', () => { - it('updates the status of snaps install privacy warning', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - appStateController.setSnapsInstallPrivacyWarningShownStatus(true); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - snapsInstallPrivacyWarningShown: true, - }); - - updateStateSpy.mockRestore(); - }); - }); - - describe('institutional', () => { - it('set the interactive replacement token with a url and the old refresh token', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = { - url: 'https://example.com', - oldRefreshToken: 'old', - }; - - appStateController.showInteractiveReplacementTokenBanner(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - interactiveReplacementToken: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - - it('set the setCustodianDeepLink with the fromAddress and custodyId', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = { - fromAddress: '0x', - custodyId: 'custodyId', - }; - - appStateController.setCustodianDeepLink(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - custodianDeepLink: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - - it('set the setNoteToTraderMessage with a message', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = 'some message'; - - appStateController.setNoteToTraderMessage(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - noteToTraderMessage: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - }); - - describe('setSurveyLinkLastClickedOrClosed', () => { - it('set the surveyLinkLastClickedOrClosed time', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = Date.now(); - - appStateController.setSurveyLinkLastClickedOrClosed(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - surveyLinkLastClickedOrClosed: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - }); - - describe('setOnboardingDate', () => { - it('set the onboardingDate', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - appStateController.setOnboardingDate(); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - - updateStateSpy.mockRestore(); - }); - }); - - describe('setLastViewedUserSurvey', () => { - it('set the lastViewedUserSurvey with id 1', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = 1; - - appStateController.setLastViewedUserSurvey(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - lastViewedUserSurvey: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - }); - - describe('setNewPrivacyPolicyToastClickedOrClosed', () => { - it('set the newPrivacyPolicyToastClickedOrClosed to true', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - appStateController.setNewPrivacyPolicyToastClickedOrClosed(); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect( - appStateController.store.getState() - .newPrivacyPolicyToastClickedOrClosed, - ).toStrictEqual(true); - - updateStateSpy.mockRestore(); - }); - }); - - describe('setNewPrivacyPolicyToastShownDate', () => { - it('set the newPrivacyPolicyToastShownDate', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = Date.now(); - - appStateController.setNewPrivacyPolicyToastShownDate(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - newPrivacyPolicyToastShownDate: mockParams, - }); - expect( - appStateController.store.getState().newPrivacyPolicyToastShownDate, - ).toStrictEqual(mockParams); - - updateStateSpy.mockRestore(); - }); - }); - - describe('setTermsOfUseLastAgreed', () => { - it('set the termsOfUseLastAgreed timestamp', () => { - ({ appStateController } = createAppStateController()); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = Date.now(); - - appStateController.setTermsOfUseLastAgreed(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - termsOfUseLastAgreed: mockParams, - }); - expect( - appStateController.store.getState().termsOfUseLastAgreed, - ).toStrictEqual(mockParams); - - updateStateSpy.mockRestore(); - }); - }); - - describe('onPreferencesStateChange', () => { - it('should update the timeoutMinutes with the autoLockTimeLimit', () => { - ({ appStateController, controllerMessenger } = - createAppStateController()); - const timeout = Date.now(); - - controllerMessenger.publish( - 'PreferencesController:stateChange', - { - preferences: { autoLockTimeLimit: timeout }, - } as unknown as PreferencesControllerState, - [], - ); - - expect(appStateController.store.getState().timeoutMinutes).toStrictEqual( - timeout, - ); - }); - }); - - describe('isManifestV3', () => { - it('creates alarm when isManifestV3 is true', () => { - mockIsManifestV3.mockReturnValue(true); - ({ appStateController } = createAppStateController()); - - const timeout = Date.now(); - controllerMessenger.publish( - 'PreferencesController:stateChange', - { - preferences: { autoLockTimeLimit: timeout }, - } as unknown as PreferencesControllerState, - [], - ); - const spy = jest.spyOn( - appStateController as unknown as { _resetTimer: () => void }, - '_resetTimer', - ); - appStateController.setLastActiveTime(); - - expect(spy).toHaveBeenCalled(); - expect(extensionMock.alarms.clear).toHaveBeenCalled(); - expect(extensionMock.alarms.onAlarm.addListener).toHaveBeenCalled(); - }); - }); - - describe('AppStateController:getState', () => { - it('should return the current state of the property', () => { - expect( - appStateController.store.getState().recoveryPhraseReminderHasBeenShown, - ).toStrictEqual(false); - expect( - controllerMessenger.call('AppStateController:getState') - .recoveryPhraseReminderHasBeenShown, - ).toStrictEqual(false); - }); - }); - - describe('AppStateController:stateChange', () => { - it('subscribers will recieve the state when published', () => { - expect( - appStateController.store.getState().surveyLinkLastClickedOrClosed, - ).toStrictEqual(null); - const timeNow = Date.now(); - controllerMessenger.subscribe( - 'AppStateController:stateChange', - (state: Partial) => { - if (typeof state.surveyLinkLastClickedOrClosed === 'number') { - appStateController.setSurveyLinkLastClickedOrClosed( - state.surveyLinkLastClickedOrClosed, - ); - } - }, - ); - - controllerMessenger.publish( - 'AppStateController:stateChange', - { - surveyLinkLastClickedOrClosed: timeNow, - } as unknown as AppStateControllerState, - [], - ); - - expect( - appStateController.store.getState().surveyLinkLastClickedOrClosed, - ).toStrictEqual(timeNow); - expect( - controllerMessenger.call('AppStateController:getState') - .surveyLinkLastClickedOrClosed, - ).toStrictEqual(timeNow); - }); - - it('state will be published when there is state change', () => { - expect( - appStateController.store.getState().surveyLinkLastClickedOrClosed, - ).toStrictEqual(null); - const timeNow = Date.now(); - controllerMessenger.subscribe( - 'AppStateController:stateChange', - (state: Partial) => { - expect(state.surveyLinkLastClickedOrClosed).toStrictEqual(timeNow); - }, - ); - - appStateController.setSurveyLinkLastClickedOrClosed(timeNow); - - expect( - appStateController.store.getState().surveyLinkLastClickedOrClosed, - ).toStrictEqual(timeNow); - expect( - controllerMessenger.call('AppStateController:getState') - .surveyLinkLastClickedOrClosed, - ).toStrictEqual(timeNow); - }); - }); -}); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts deleted file mode 100644 index e76b8fe3888..00000000000 --- a/app/scripts/controllers/app-state-controller.ts +++ /dev/null @@ -1,874 +0,0 @@ -import EventEmitter from 'events'; -import { ObservableStore } from '@metamask/obs-store'; -import { v4 as uuid } from 'uuid'; -import log from 'loglevel'; -import { ApprovalType } from '@metamask/controller-utils'; -import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; -import { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { - AcceptRequest, - AddApprovalRequest, -} from '@metamask/approval-controller'; -import { Json } from '@metamask/utils'; -import { Browser } from 'webextension-polyfill'; -import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; -import { MINUTE } from '../../../shared/constants/time'; -import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; -import { isManifestV3 } from '../../../shared/modules/mv3.utils'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { isBeta } from '../../../ui/helpers/utils/build-types'; -import { - ENVIRONMENT_TYPE_BACKGROUND, - POLLING_TOKEN_ENVIRONMENT_TYPES, - ORIGIN_METAMASK, -} from '../../../shared/constants/app'; -import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; -import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; -import { SecurityAlertResponse } from '../lib/ppom/types'; -import type { - Preferences, - PreferencesControllerGetStateAction, - PreferencesControllerStateChangeEvent, -} from './preferences-controller'; - -export type AppStateControllerState = { - timeoutMinutes: number; - connectedStatusPopoverHasBeenShown: boolean; - defaultHomeActiveTabName: string | null; - browserEnvironment: Record; - popupGasPollTokens: string[]; - notificationGasPollTokens: string[]; - fullScreenGasPollTokens: string[]; - recoveryPhraseReminderHasBeenShown: boolean; - recoveryPhraseReminderLastShown: number; - outdatedBrowserWarningLastShown: number | null; - nftsDetectionNoticeDismissed: boolean; - showTestnetMessageInDropdown: boolean; - showBetaHeader: boolean; - showPermissionsTour: boolean; - showNetworkBanner: boolean; - showAccountBanner: boolean; - trezorModel: string | null; - currentPopupId?: number; - onboardingDate: number | null; - lastViewedUserSurvey: number | null; - newPrivacyPolicyToastClickedOrClosed: boolean | null; - newPrivacyPolicyToastShownDate: number | null; - // This key is only used for checking if the user had set advancedGasFee - // prior to Migration 92.3 where we split out the setting to support - // multiple networks. - hadAdvancedGasFeesSetPriorToMigration92_3: boolean; - qrHardware: Json; - nftsDropdownState: Json; - usedNetworks: Record; - surveyLinkLastClickedOrClosed: number | null; - signatureSecurityAlertResponses: Record; - // States used for displaying the changed network toast - switchedNetworkDetails: Record | null; - switchedNetworkNeverShowMessage: boolean; - currentExtensionPopupId: number; - lastInteractedConfirmationInfo?: LastInteractedConfirmationInfo; - termsOfUseLastAgreed?: number; - snapsInstallPrivacyWarningShown?: boolean; - interactiveReplacementToken?: { url: string; oldRefreshToken: string }; - noteToTraderMessage?: string; - custodianDeepLink?: { fromAddress: string; custodyId: string }; -}; - -const controllerName = 'AppStateController'; - -/** - * Returns the state of the {@link AppStateController}. - */ -export type AppStateControllerGetStateAction = { - type: 'AppStateController:getState'; - handler: () => AppStateControllerState; -}; - -/** - * Actions exposed by the {@link AppStateController}. - */ -export type AppStateControllerActions = AppStateControllerGetStateAction; - -/** - * Actions that this controller is allowed to call. - */ -export type AllowedActions = - | AddApprovalRequest - | AcceptRequest - | PreferencesControllerGetStateAction; - -/** - * Event emitted when the state of the {@link AppStateController} changes. - */ -export type AppStateControllerStateChangeEvent = { - type: 'AppStateController:stateChange'; - payload: [AppStateControllerState, []]; -}; - -/** - * Events emitted by {@link AppStateController}. - */ -export type AppStateControllerEvents = AppStateControllerStateChangeEvent; - -/** - * Events that this controller is allowed to subscribe. - */ -export type AllowedEvents = - | PreferencesControllerStateChangeEvent - | KeyringControllerQRKeyringStateChangeEvent; - -export type AppStateControllerMessenger = RestrictedControllerMessenger< - typeof controllerName, - AppStateControllerActions | AllowedActions, - AppStateControllerEvents | AllowedEvents, - AllowedActions['type'], - AllowedEvents['type'] ->; - -type PollingTokenType = - | 'popupGasPollTokens' - | 'notificationGasPollTokens' - | 'fullScreenGasPollTokens'; - -type AppStateControllerInitState = Partial< - Omit< - AppStateControllerState, - | 'qrHardware' - | 'nftsDropdownState' - | 'usedNetworks' - | 'surveyLinkLastClickedOrClosed' - | 'signatureSecurityAlertResponses' - | 'switchedNetworkDetails' - | 'switchedNetworkNeverShowMessage' - | 'currentExtensionPopupId' - > ->; - -type AppStateControllerOptions = { - addUnlockListener: (callback: () => void) => void; - isUnlocked: () => boolean; - initState?: AppStateControllerInitState; - onInactiveTimeout?: () => void; - messenger: AppStateControllerMessenger; - extension: Browser; -}; - -const getDefaultAppStateControllerState = ( - initState?: AppStateControllerInitState, -): AppStateControllerState => ({ - timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, - connectedStatusPopoverHasBeenShown: true, - defaultHomeActiveTabName: null, - browserEnvironment: {}, - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - recoveryPhraseReminderHasBeenShown: false, - recoveryPhraseReminderLastShown: new Date().getTime(), - outdatedBrowserWarningLastShown: null, - nftsDetectionNoticeDismissed: false, - showTestnetMessageInDropdown: true, - showBetaHeader: isBeta(), - showPermissionsTour: true, - showNetworkBanner: true, - showAccountBanner: true, - trezorModel: null, - onboardingDate: null, - lastViewedUserSurvey: null, - newPrivacyPolicyToastClickedOrClosed: null, - newPrivacyPolicyToastShownDate: null, - hadAdvancedGasFeesSetPriorToMigration92_3: false, - ...initState, - qrHardware: {}, - nftsDropdownState: {}, - usedNetworks: { - '0x1': true, - '0x5': true, - '0x539': true, - }, - surveyLinkLastClickedOrClosed: null, - signatureSecurityAlertResponses: {}, - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage: false, - currentExtensionPopupId: 0, -}); - -export class AppStateController extends EventEmitter { - private readonly extension: AppStateControllerOptions['extension']; - - private readonly onInactiveTimeout: () => void; - - store: ObservableStore; - - private timer: NodeJS.Timeout | null; - - isUnlocked: () => boolean; - - private readonly waitingForUnlock: { resolve: () => void }[]; - - private readonly messagingSystem: AppStateControllerMessenger; - - #approvalRequestId: string | null; - - constructor(opts: AppStateControllerOptions) { - const { - addUnlockListener, - isUnlocked, - initState, - onInactiveTimeout, - messenger, - extension, - } = opts; - super(); - - this.extension = extension; - this.onInactiveTimeout = onInactiveTimeout || (() => undefined); - this.store = new ObservableStore( - getDefaultAppStateControllerState(initState), - ); - this.timer = null; - - this.isUnlocked = isUnlocked; - this.waitingForUnlock = []; - addUnlockListener(this.handleUnlock.bind(this)); - - messenger.subscribe( - 'PreferencesController:stateChange', - ({ preferences }: { preferences: Partial }) => { - const currentState = this.store.getState(); - if ( - typeof preferences?.autoLockTimeLimit === 'number' && - currentState.timeoutMinutes !== preferences.autoLockTimeLimit - ) { - this._setInactiveTimeout(preferences.autoLockTimeLimit); - } - }, - ); - - messenger.subscribe( - 'KeyringController:qrKeyringStateChange', - (qrHardware: Json) => - this.store.updateState({ - qrHardware, - }), - ); - - const { preferences } = messenger.call('PreferencesController:getState'); - if (typeof preferences.autoLockTimeLimit === 'number') { - this._setInactiveTimeout(preferences.autoLockTimeLimit); - } - - this.messagingSystem = messenger; - this.messagingSystem.registerActionHandler( - 'AppStateController:getState', - () => this.store.getState(), - ); - this.store.subscribe((state: AppStateControllerState) => { - this.messagingSystem.publish('AppStateController:stateChange', state, []); - }); - this.#approvalRequestId = null; - } - - /** - * Get a Promise that resolves when the extension is unlocked. - * This Promise will never reject. - * - * @param shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - * @returns A promise that resolves when the extension is - * unlocked, or immediately if the extension is already unlocked. - */ - getUnlockPromise(shouldShowUnlockRequest: boolean): Promise { - return new Promise((resolve) => { - if (this.isUnlocked()) { - resolve(); - } else { - this.waitForUnlock(resolve, shouldShowUnlockRequest); - } - }); - } - - /** - * Adds a Promise's resolve function to the waitingForUnlock queue. - * Also opens the extension popup if specified. - * - * @param resolve - A Promise's resolve function that will - * be called when the extension is unlocked. - * @param shouldShowUnlockRequest - Whether the extension notification - * popup should be opened. - */ - waitForUnlock(resolve: () => void, shouldShowUnlockRequest: boolean): void { - this.waitingForUnlock.push({ resolve }); - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - if (shouldShowUnlockRequest) { - this._requestApproval(); - } - } - - /** - * Drains the waitingForUnlock queue, resolving all the related Promises. - */ - handleUnlock(): void { - if (this.waitingForUnlock.length > 0) { - while (this.waitingForUnlock.length > 0) { - this.waitingForUnlock.shift()?.resolve(); - } - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - } - - this._acceptApproval(); - } - - /** - * Sets the default home tab - * - * @param defaultHomeActiveTabName - the tab name - */ - setDefaultHomeActiveTabName(defaultHomeActiveTabName: string | null): void { - this.store.updateState({ - defaultHomeActiveTabName, - }); - } - - /** - * Record that the user has seen the connected status info popover - */ - setConnectedStatusPopoverHasBeenShown(): void { - this.store.updateState({ - connectedStatusPopoverHasBeenShown: true, - }); - } - - /** - * Record that the user has been shown the recovery phrase reminder. - */ - setRecoveryPhraseReminderHasBeenShown(): void { - this.store.updateState({ - recoveryPhraseReminderHasBeenShown: true, - }); - } - - setSurveyLinkLastClickedOrClosed(time: number): void { - this.store.updateState({ - surveyLinkLastClickedOrClosed: time, - }); - } - - setOnboardingDate(): void { - this.store.updateState({ - onboardingDate: Date.now(), - }); - } - - setLastViewedUserSurvey(id: number) { - this.store.updateState({ - lastViewedUserSurvey: id, - }); - } - - setNewPrivacyPolicyToastClickedOrClosed(): void { - this.store.updateState({ - newPrivacyPolicyToastClickedOrClosed: true, - }); - } - - setNewPrivacyPolicyToastShownDate(time: number): void { - this.store.updateState({ - newPrivacyPolicyToastShownDate: time, - }); - } - - /** - * Record the timestamp of the last time the user has seen the recovery phrase reminder - * - * @param lastShown - timestamp when user was last shown the reminder. - */ - setRecoveryPhraseReminderLastShown(lastShown: number): void { - this.store.updateState({ - recoveryPhraseReminderLastShown: lastShown, - }); - } - - /** - * Record the timestamp of the last time the user has acceoted the terms of use - * - * @param lastAgreed - timestamp when user last accepted the terms of use - */ - setTermsOfUseLastAgreed(lastAgreed: number): void { - this.store.updateState({ - termsOfUseLastAgreed: lastAgreed, - }); - } - - /** - * Record if popover for snaps privacy warning has been shown - * on the first install of a snap. - * - * @param shown - shown status - */ - setSnapsInstallPrivacyWarningShownStatus(shown: boolean): void { - this.store.updateState({ - snapsInstallPrivacyWarningShown: shown, - }); - } - - /** - * Record the timestamp of the last time the user has seen the outdated browser warning - * - * @param lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. - */ - setOutdatedBrowserWarningLastShown(lastShown: number): void { - this.store.updateState({ - outdatedBrowserWarningLastShown: lastShown, - }); - } - - /** - * Sets the last active time to the current time. - */ - setLastActiveTime(): void { - this._resetTimer(); - } - - /** - * Sets the inactive timeout for the app - * - * @param timeoutMinutes - The inactive timeout in minutes. - */ - private _setInactiveTimeout(timeoutMinutes: number): void { - this.store.updateState({ - timeoutMinutes, - }); - - this._resetTimer(); - } - - /** - * Resets the internal inactive timer - * - * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new - * timer will not be created. - * - */ - private _resetTimer(): void { - const { timeoutMinutes } = this.store.getState(); - - if (this.timer) { - clearTimeout(this.timer); - } else if (isManifestV3) { - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - - if (!timeoutMinutes) { - return; - } - - // This is a temporary fix until we add a state migration. - // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, - // it was possible for timeoutMinutes to be saved as a string, as explained - // in PR 25109. `alarms.create` will fail in that case. We are - // converting this to a number here to prevent that failure. Once - // we add a migration to update the malformed state to the right type, - // we will remove this conversion. - const timeoutToSet = Number(timeoutMinutes); - - if (isManifestV3) { - this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { - delayInMinutes: timeoutToSet, - periodInMinutes: timeoutToSet, - }); - this.extension.alarms.onAlarm.addListener( - (alarmInfo: { name: string }) => { - if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - }, - ); - } else { - this.timer = setTimeout( - () => this.onInactiveTimeout(), - timeoutToSet * MINUTE, - ); - } - } - - /** - * Sets the current browser and OS environment - * - * @param os - * @param browser - */ - setBrowserEnvironment(os: string, browser: string): void { - this.store.updateState({ browserEnvironment: { os, browser } }); - } - - /** - * Adds a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - addPollingToken( - pollingToken: string, - pollingTokenType: PollingTokenType, - ): void { - if ( - pollingTokenType.toString() !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - if (this.#isValidPollingTokenType(pollingTokenType)) { - this.#updatePollingTokens(pollingToken, pollingTokenType); - } - } - } - - /** - * Updates the polling token in the state. - * - * @param pollingToken - * @param pollingTokenType - */ - #updatePollingTokens( - pollingToken: string, - pollingTokenType: PollingTokenType, - ) { - const currentTokens: string[] = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: [...currentTokens, pollingToken], - }); - } - - /** - * removes a pollingToken for a given environmentType - * - * @param pollingToken - * @param pollingTokenType - */ - removePollingToken( - pollingToken: string, - pollingTokenType: PollingTokenType, - ): void { - if ( - pollingTokenType.toString() !== - POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] - ) { - const currentTokens: string[] = this.store.getState()[pollingTokenType]; - if (this.#isValidPollingTokenType(pollingTokenType)) { - this.store.updateState({ - [pollingTokenType]: currentTokens.filter( - (token: string) => token !== pollingToken, - ), - }); - } - } - } - - /** - * Validates whether the given polling token type is a valid one. - * - * @param pollingTokenType - * @returns true if valid, false otherwise. - */ - #isValidPollingTokenType(pollingTokenType: PollingTokenType): boolean { - const validTokenTypes: PollingTokenType[] = [ - 'popupGasPollTokens', - 'notificationGasPollTokens', - 'fullScreenGasPollTokens', - ]; - - return validTokenTypes.includes(pollingTokenType); - } - - /** - * clears all pollingTokens - */ - clearPollingTokens(): void { - this.store.updateState({ - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - }); - } - - /** - * Sets whether the testnet dismissal link should be shown in the network dropdown - * - * @param showTestnetMessageInDropdown - */ - setShowTestnetMessageInDropdown(showTestnetMessageInDropdown: boolean): void { - this.store.updateState({ showTestnetMessageInDropdown }); - } - - /** - * Sets whether the beta notification heading on the home page - * - * @param showBetaHeader - */ - setShowBetaHeader(showBetaHeader: boolean): void { - this.store.updateState({ showBetaHeader }); - } - - /** - * Sets whether the permissions tour should be shown to the user - * - * @param showPermissionsTour - */ - setShowPermissionsTour(showPermissionsTour: boolean): void { - this.store.updateState({ showPermissionsTour }); - } - - /** - * Sets whether the Network Banner should be shown - * - * @param showNetworkBanner - */ - setShowNetworkBanner(showNetworkBanner: boolean): void { - this.store.updateState({ showNetworkBanner }); - } - - /** - * Sets whether the Account Banner should be shown - * - * @param showAccountBanner - */ - setShowAccountBanner(showAccountBanner: boolean): void { - this.store.updateState({ showAccountBanner }); - } - - /** - * Sets a unique ID for the current extension popup - * - * @param currentExtensionPopupId - */ - setCurrentExtensionPopupId(currentExtensionPopupId: number): void { - this.store.updateState({ currentExtensionPopupId }); - } - - /** - * Sets an object with networkName and appName - * or `null` if the message is meant to be cleared - * - * @param switchedNetworkDetails - Details about the network that MetaMask just switched to. - */ - setSwitchedNetworkDetails( - switchedNetworkDetails: { origin: string; networkClientId: string } | null, - ): void { - this.store.updateState({ switchedNetworkDetails }); - } - - /** - * Clears the switched network details in state - */ - clearSwitchedNetworkDetails(): void { - this.store.updateState({ switchedNetworkDetails: null }); - } - - /** - * Remembers if the user prefers to never see the - * network switched message again - * - * @param switchedNetworkNeverShowMessage - */ - setSwitchedNetworkNeverShowMessage( - switchedNetworkNeverShowMessage: boolean, - ): void { - this.store.updateState({ - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage, - }); - } - - /** - * Sets a property indicating the model of the user's Trezor hardware wallet - * - * @param trezorModel - The Trezor model. - */ - setTrezorModel(trezorModel: string | null): void { - this.store.updateState({ trezorModel }); - } - - /** - * A setter for the `nftsDropdownState` property - * - * @param nftsDropdownState - */ - updateNftDropDownState(nftsDropdownState: Json): void { - this.store.updateState({ - nftsDropdownState, - }); - } - - /** - * Updates the array of the first time used networks - * - * @param chainId - */ - setFirstTimeUsedNetwork(chainId: string): void { - const currentState = this.store.getState(); - const { usedNetworks } = currentState; - usedNetworks[chainId] = true; - - this.store.updateState({ usedNetworks }); - } - - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - /** - * Set the interactive replacement token with a url and the old refresh token - * - * @param opts - * @param opts.url - * @param opts.oldRefreshToken - */ - showInteractiveReplacementTokenBanner({ - url, - oldRefreshToken, - }: { - url: string; - oldRefreshToken: string; - }): void { - this.store.updateState({ - interactiveReplacementToken: { - url, - oldRefreshToken, - }, - }); - } - - /** - * Set the setCustodianDeepLink with the fromAddress and custodyId - * - * @param opts - * @param opts.fromAddress - * @param opts.custodyId - */ - setCustodianDeepLink({ - fromAddress, - custodyId, - }: { - fromAddress: string; - custodyId: string; - }): void { - this.store.updateState({ - custodianDeepLink: { fromAddress, custodyId }, - }); - } - - setNoteToTraderMessage(message: string): void { - this.store.updateState({ - noteToTraderMessage: message, - }); - } - - ///: END:ONLY_INCLUDE_IF - - getSignatureSecurityAlertResponse( - securityAlertId: string, - ): SecurityAlertResponse { - return this.store.getState().signatureSecurityAlertResponses[ - securityAlertId - ]; - } - - addSignatureSecurityAlertResponse( - securityAlertResponse: SecurityAlertResponse, - ): void { - const currentState = this.store.getState(); - const { signatureSecurityAlertResponses } = currentState; - if (securityAlertResponse.securityAlertId) { - this.store.updateState({ - signatureSecurityAlertResponses: { - ...signatureSecurityAlertResponses, - [String(securityAlertResponse.securityAlertId)]: - securityAlertResponse, - }, - }); - } - } - - /** - * A setter for the currentPopupId which indicates the id of popup window that's currently active - * - * @param currentPopupId - */ - setCurrentPopupId(currentPopupId: number): void { - this.store.updateState({ - currentPopupId, - }); - } - - /** - * The function returns information about the last confirmation user interacted with - */ - getLastInteractedConfirmationInfo(): - | LastInteractedConfirmationInfo - | undefined { - return this.store.getState().lastInteractedConfirmationInfo; - } - - /** - * Update the information about the last confirmation user interacted with - * - * @param lastInteractedConfirmationInfo - */ - setLastInteractedConfirmationInfo( - lastInteractedConfirmationInfo: LastInteractedConfirmationInfo | undefined, - ): void { - this.store.updateState({ - lastInteractedConfirmationInfo, - }); - } - - /** - * A getter to retrieve currentPopupId saved in the appState - */ - getCurrentPopupId(): number | undefined { - return this.store.getState().currentPopupId; - } - - private _requestApproval(): void { - // If we already have a pending request this is a no-op - if (this.#approvalRequestId) { - return; - } - this.#approvalRequestId = uuid(); - - this.messagingSystem - .call( - 'ApprovalController:addRequest', - { - id: this.#approvalRequestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.Unlock, - }, - true, - ) - .catch(() => { - // If the promise fails, we allow a new popup to be triggered - this.#approvalRequestId = null; - }); - } - - // Override emit method to provide strong typing for events - emit(event: string) { - return super.emit(event); - } - - private _acceptApproval(): void { - if (!this.#approvalRequestId) { - return; - } - try { - this.messagingSystem.call( - 'ApprovalController:acceptRequest', - this.#approvalRequestId, - ); - } catch (error) { - log.error('Failed to unlock approval request', error); - } - - this.#approvalRequestId = null; - } -} diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js new file mode 100644 index 00000000000..9dabf2313e5 --- /dev/null +++ b/app/scripts/controllers/app-state.js @@ -0,0 +1,651 @@ +import EventEmitter from 'events'; +import { ObservableStore } from '@metamask/obs-store'; +import { v4 as uuid } from 'uuid'; +import log from 'loglevel'; +import { ApprovalType } from '@metamask/controller-utils'; +import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; +import { MINUTE } from '../../../shared/constants/time'; +import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; +import { isManifestV3 } from '../../../shared/modules/mv3.utils'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { isBeta } from '../../../ui/helpers/utils/build-types'; +import { + ENVIRONMENT_TYPE_BACKGROUND, + POLLING_TOKEN_ENVIRONMENT_TYPES, + ORIGIN_METAMASK, +} from '../../../shared/constants/app'; +import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; + +/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ + +export default class AppStateController extends EventEmitter { + /** + * @param {object} opts + */ + constructor(opts = {}) { + const { + addUnlockListener, + isUnlocked, + initState, + onInactiveTimeout, + preferencesController, + messenger, + extension, + } = opts; + super(); + + this.extension = extension; + this.onInactiveTimeout = onInactiveTimeout || (() => undefined); + this.store = new ObservableStore({ + timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, + connectedStatusPopoverHasBeenShown: true, + defaultHomeActiveTabName: null, + browserEnvironment: {}, + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: new Date().getTime(), + outdatedBrowserWarningLastShown: null, + nftsDetectionNoticeDismissed: false, + showTestnetMessageInDropdown: true, + showBetaHeader: isBeta(), + showPermissionsTour: true, + showNetworkBanner: true, + showAccountBanner: true, + trezorModel: null, + currentPopupId: undefined, + onboardingDate: null, + lastViewedUserSurvey: null, + newPrivacyPolicyToastClickedOrClosed: null, + newPrivacyPolicyToastShownDate: null, + // This key is only used for checking if the user had set advancedGasFee + // prior to Migration 92.3 where we split out the setting to support + // multiple networks. + hadAdvancedGasFeesSetPriorToMigration92_3: false, + ...initState, + qrHardware: {}, + nftsDropdownState: {}, + usedNetworks: { + '0x1': true, + '0x5': true, + '0x539': true, + }, + surveyLinkLastClickedOrClosed: null, + signatureSecurityAlertResponses: {}, + // States used for displaying the changed network toast + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage: false, + currentExtensionPopupId: 0, + lastInteractedConfirmationInfo: undefined, + }); + this.timer = null; + + this.isUnlocked = isUnlocked; + this.waitingForUnlock = []; + addUnlockListener(this.handleUnlock.bind(this)); + + messenger.subscribe( + 'PreferencesController:stateChange', + ({ preferences }) => { + const currentState = this.store.getState(); + if ( + preferences && + currentState.timeoutMinutes !== preferences.autoLockTimeLimit + ) { + this._setInactiveTimeout(preferences.autoLockTimeLimit); + } + }, + ); + + messenger.subscribe( + 'KeyringController:qrKeyringStateChange', + (qrHardware) => + this.store.updateState({ + qrHardware, + }), + ); + + const { preferences } = preferencesController.state; + + this._setInactiveTimeout(preferences.autoLockTimeLimit); + + this.messagingSystem = messenger; + this._approvalRequestId = null; + } + + /** + * Get a Promise that resolves when the extension is unlocked. + * This Promise will never reject. + * + * @param {boolean} shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + * @returns {Promise} A promise that resolves when the extension is + * unlocked, or immediately if the extension is already unlocked. + */ + getUnlockPromise(shouldShowUnlockRequest) { + return new Promise((resolve) => { + if (this.isUnlocked()) { + resolve(); + } else { + this.waitForUnlock(resolve, shouldShowUnlockRequest); + } + }); + } + + /** + * Adds a Promise's resolve function to the waitingForUnlock queue. + * Also opens the extension popup if specified. + * + * @param {Promise.resolve} resolve - A Promise's resolve function that will + * be called when the extension is unlocked. + * @param {boolean} shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + */ + waitForUnlock(resolve, shouldShowUnlockRequest) { + this.waitingForUnlock.push({ resolve }); + this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); + if (shouldShowUnlockRequest) { + this._requestApproval(); + } + } + + /** + * Drains the waitingForUnlock queue, resolving all the related Promises. + */ + handleUnlock() { + if (this.waitingForUnlock.length > 0) { + while (this.waitingForUnlock.length > 0) { + this.waitingForUnlock.shift().resolve(); + } + this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); + } + + this._acceptApproval(); + } + + /** + * Sets the default home tab + * + * @param {string} [defaultHomeActiveTabName] - the tab name + */ + setDefaultHomeActiveTabName(defaultHomeActiveTabName) { + this.store.updateState({ + defaultHomeActiveTabName, + }); + } + + /** + * Record that the user has seen the connected status info popover + */ + setConnectedStatusPopoverHasBeenShown() { + this.store.updateState({ + connectedStatusPopoverHasBeenShown: true, + }); + } + + /** + * Record that the user has been shown the recovery phrase reminder. + */ + setRecoveryPhraseReminderHasBeenShown() { + this.store.updateState({ + recoveryPhraseReminderHasBeenShown: true, + }); + } + + setSurveyLinkLastClickedOrClosed(time) { + this.store.updateState({ + surveyLinkLastClickedOrClosed: time, + }); + } + + setOnboardingDate() { + this.store.updateState({ + onboardingDate: Date.now(), + }); + } + + setLastViewedUserSurvey(id) { + this.store.updateState({ + lastViewedUserSurvey: id, + }); + } + + setNewPrivacyPolicyToastClickedOrClosed() { + this.store.updateState({ + newPrivacyPolicyToastClickedOrClosed: true, + }); + } + + setNewPrivacyPolicyToastShownDate(time) { + this.store.updateState({ + newPrivacyPolicyToastShownDate: time, + }); + } + + /** + * Record the timestamp of the last time the user has seen the recovery phrase reminder + * + * @param {number} lastShown - timestamp when user was last shown the reminder. + */ + setRecoveryPhraseReminderLastShown(lastShown) { + this.store.updateState({ + recoveryPhraseReminderLastShown: lastShown, + }); + } + + /** + * Record the timestamp of the last time the user has acceoted the terms of use + * + * @param {number} lastAgreed - timestamp when user last accepted the terms of use + */ + setTermsOfUseLastAgreed(lastAgreed) { + this.store.updateState({ + termsOfUseLastAgreed: lastAgreed, + }); + } + + /** + * Record if popover for snaps privacy warning has been shown + * on the first install of a snap. + * + * @param {boolean} shown - shown status + */ + setSnapsInstallPrivacyWarningShownStatus(shown) { + this.store.updateState({ + snapsInstallPrivacyWarningShown: shown, + }); + } + + /** + * Record the timestamp of the last time the user has seen the outdated browser warning + * + * @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. + */ + setOutdatedBrowserWarningLastShown(lastShown) { + this.store.updateState({ + outdatedBrowserWarningLastShown: lastShown, + }); + } + + /** + * Sets the last active time to the current time. + */ + setLastActiveTime() { + this._resetTimer(); + } + + /** + * Sets the inactive timeout for the app + * + * @private + * @param {number} timeoutMinutes - The inactive timeout in minutes. + */ + _setInactiveTimeout(timeoutMinutes) { + this.store.updateState({ + timeoutMinutes, + }); + + this._resetTimer(); + } + + /** + * Resets the internal inactive timer + * + * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new + * timer will not be created. + * + * @private + */ + /* eslint-disable no-undef */ + _resetTimer() { + const { timeoutMinutes } = this.store.getState(); + + if (this.timer) { + clearTimeout(this.timer); + } else if (isManifestV3) { + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + + if (!timeoutMinutes) { + return; + } + + // This is a temporary fix until we add a state migration. + // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, + // it was possible for timeoutMinutes to be saved as a string, as explained + // in PR 25109. `alarms.create` will fail in that case. We are + // converting this to a number here to prevent that failure. Once + // we add a migration to update the malformed state to the right type, + // we will remove this conversion. + const timeoutToSet = Number(timeoutMinutes); + + if (isManifestV3) { + this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { + delayInMinutes: timeoutToSet, + periodInMinutes: timeoutToSet, + }); + this.extension.alarms.onAlarm.addListener((alarmInfo) => { + if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { + this.onInactiveTimeout(); + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + }); + } else { + this.timer = setTimeout( + () => this.onInactiveTimeout(), + timeoutToSet * MINUTE, + ); + } + } + + /** + * Sets the current browser and OS environment + * + * @param os + * @param browser + */ + setBrowserEnvironment(os, browser) { + this.store.updateState({ browserEnvironment: { os, browser } }); + } + + /** + * Adds a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + addPollingToken(pollingToken, pollingTokenType) { + if ( + pollingTokenType !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + const prevState = this.store.getState()[pollingTokenType]; + this.store.updateState({ + [pollingTokenType]: [...prevState, pollingToken], + }); + } + } + + /** + * removes a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + removePollingToken(pollingToken, pollingTokenType) { + if ( + pollingTokenType !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + const prevState = this.store.getState()[pollingTokenType]; + this.store.updateState({ + [pollingTokenType]: prevState.filter((token) => token !== pollingToken), + }); + } + } + + /** + * clears all pollingTokens + */ + clearPollingTokens() { + this.store.updateState({ + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + }); + } + + /** + * Sets whether the testnet dismissal link should be shown in the network dropdown + * + * @param showTestnetMessageInDropdown + */ + setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { + this.store.updateState({ showTestnetMessageInDropdown }); + } + + /** + * Sets whether the beta notification heading on the home page + * + * @param showBetaHeader + */ + setShowBetaHeader(showBetaHeader) { + this.store.updateState({ showBetaHeader }); + } + + /** + * Sets whether the permissions tour should be shown to the user + * + * @param showPermissionsTour + */ + setShowPermissionsTour(showPermissionsTour) { + this.store.updateState({ showPermissionsTour }); + } + + /** + * Sets whether the Network Banner should be shown + * + * @param showNetworkBanner + */ + setShowNetworkBanner(showNetworkBanner) { + this.store.updateState({ showNetworkBanner }); + } + + /** + * Sets whether the Account Banner should be shown + * + * @param showAccountBanner + */ + setShowAccountBanner(showAccountBanner) { + this.store.updateState({ showAccountBanner }); + } + + /** + * Sets a unique ID for the current extension popup + * + * @param currentExtensionPopupId + */ + setCurrentExtensionPopupId(currentExtensionPopupId) { + this.store.updateState({ currentExtensionPopupId }); + } + + /** + * Sets an object with networkName and appName + * or `null` if the message is meant to be cleared + * + * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. + */ + setSwitchedNetworkDetails(switchedNetworkDetails) { + this.store.updateState({ switchedNetworkDetails }); + } + + /** + * Clears the switched network details in state + */ + clearSwitchedNetworkDetails() { + this.store.updateState({ switchedNetworkDetails: null }); + } + + /** + * Remembers if the user prefers to never see the + * network switched message again + * + * @param {boolean} switchedNetworkNeverShowMessage + */ + setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { + this.store.updateState({ + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage, + }); + } + + /** + * Sets a property indicating the model of the user's Trezor hardware wallet + * + * @param trezorModel - The Trezor model. + */ + setTrezorModel(trezorModel) { + this.store.updateState({ trezorModel }); + } + + /** + * A setter for the `nftsDropdownState` property + * + * @param nftsDropdownState + */ + updateNftDropDownState(nftsDropdownState) { + this.store.updateState({ + nftsDropdownState, + }); + } + + /** + * Updates the array of the first time used networks + * + * @param chainId + * @returns {void} + */ + setFirstTimeUsedNetwork(chainId) { + const currentState = this.store.getState(); + const { usedNetworks } = currentState; + usedNetworks[chainId] = true; + + this.store.updateState({ usedNetworks }); + } + + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + /** + * Set the interactive replacement token with a url and the old refresh token + * + * @param {object} opts + * @param opts.url + * @param opts.oldRefreshToken + * @returns {void} + */ + showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) { + this.store.updateState({ + interactiveReplacementToken: { + url, + oldRefreshToken, + }, + }); + } + + /** + * Set the setCustodianDeepLink with the fromAddress and custodyId + * + * @param {object} opts + * @param opts.fromAddress + * @param opts.custodyId + * @returns {void} + */ + setCustodianDeepLink({ fromAddress, custodyId }) { + this.store.updateState({ + custodianDeepLink: { fromAddress, custodyId }, + }); + } + + setNoteToTraderMessage(message) { + this.store.updateState({ + noteToTraderMessage: message, + }); + } + + ///: END:ONLY_INCLUDE_IF + + getSignatureSecurityAlertResponse(securityAlertId) { + return this.store.getState().signatureSecurityAlertResponses[ + securityAlertId + ]; + } + + addSignatureSecurityAlertResponse(securityAlertResponse) { + const currentState = this.store.getState(); + const { signatureSecurityAlertResponses } = currentState; + this.store.updateState({ + signatureSecurityAlertResponses: { + ...signatureSecurityAlertResponses, + [securityAlertResponse.securityAlertId]: securityAlertResponse, + }, + }); + } + + /** + * A setter for the currentPopupId which indicates the id of popup window that's currently active + * + * @param currentPopupId + */ + setCurrentPopupId(currentPopupId) { + this.store.updateState({ + currentPopupId, + }); + } + + /** + * The function returns information about the last confirmation user interacted with + * + * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. + */ + getLastInteractedConfirmationInfo() { + return this.store.getState().lastInteractedConfirmationInfo; + } + + /** + * Update the information about the last confirmation user interacted with + * + * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. + */ + setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { + this.store.updateState({ + lastInteractedConfirmationInfo, + }); + } + + /** + * A getter to retrieve currentPopupId saved in the appState + */ + getCurrentPopupId() { + return this.store.getState().currentPopupId; + } + + _requestApproval() { + // If we already have a pending request this is a no-op + if (this._approvalRequestId) { + return; + } + this._approvalRequestId = uuid(); + + this.messagingSystem + .call( + 'ApprovalController:addRequest', + { + id: this._approvalRequestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.Unlock, + }, + true, + ) + .catch(() => { + // If the promise fails, we allow a new popup to be triggered + this._approvalRequestId = null; + }); + } + + _acceptApproval() { + if (!this._approvalRequestId) { + return; + } + try { + this.messagingSystem.call( + 'ApprovalController:acceptRequest', + this._approvalRequestId, + ); + } catch (error) { + log.error('Failed to unlock approval request', error); + } + + this._approvalRequestId = null; + } +} diff --git a/app/scripts/controllers/app-state.test.js b/app/scripts/controllers/app-state.test.js new file mode 100644 index 00000000000..46fe87d29ad --- /dev/null +++ b/app/scripts/controllers/app-state.test.js @@ -0,0 +1,396 @@ +import { ObservableStore } from '@metamask/obs-store'; +import { ORIGIN_METAMASK } from '../../../shared/constants/app'; +import AppStateController from './app-state'; + +let appStateController, mockStore; + +describe('AppStateController', () => { + mockStore = new ObservableStore(); + const createAppStateController = (initState = {}) => { + return new AppStateController({ + addUnlockListener: jest.fn(), + isUnlocked: jest.fn(() => true), + initState, + onInactiveTimeout: jest.fn(), + showUnlockRequest: jest.fn(), + preferencesController: { + state: { + preferences: { + autoLockTimeLimit: 0, + }, + }, + }, + messenger: { + call: jest.fn(() => ({ + catch: jest.fn(), + })), + subscribe: jest.fn(), + }, + }); + }; + + beforeEach(() => { + appStateController = createAppStateController({ store: mockStore }); + }); + + describe('setOutdatedBrowserWarningLastShown', () => { + it('sets the last shown time', () => { + appStateController = createAppStateController(); + const date = new Date(); + + appStateController.setOutdatedBrowserWarningLastShown(date); + + expect( + appStateController.store.getState().outdatedBrowserWarningLastShown, + ).toStrictEqual(date); + }); + + it('sets outdated browser warning last shown timestamp', () => { + const lastShownTimestamp = Date.now(); + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setOutdatedBrowserWarningLastShown(lastShownTimestamp); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + outdatedBrowserWarningLastShown: lastShownTimestamp, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('getUnlockPromise', () => { + it('waits for unlock if the extension is locked', async () => { + appStateController = createAppStateController(); + const isUnlockedMock = jest + .spyOn(appStateController, 'isUnlocked') + .mockReturnValue(false); + const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); + + appStateController.getUnlockPromise(true); + expect(isUnlockedMock).toHaveBeenCalled(); + expect(waitForUnlockSpy).toHaveBeenCalledWith(expect.any(Function), true); + }); + + it('resolves immediately if the extension is already unlocked', async () => { + appStateController = createAppStateController(); + const isUnlockedMock = jest + .spyOn(appStateController, 'isUnlocked') + .mockReturnValue(true); + + await expect( + appStateController.getUnlockPromise(false), + ).resolves.toBeUndefined(); + + expect(isUnlockedMock).toHaveBeenCalled(); + }); + }); + + describe('waitForUnlock', () => { + it('resolves immediately if already unlocked', async () => { + const emitSpy = jest.spyOn(appStateController, 'emit'); + const resolveFn = jest.fn(); + appStateController.waitForUnlock(resolveFn, false); + expect(emitSpy).toHaveBeenCalledWith('updateBadge'); + expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(0); + }); + + it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { + jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + + const resolveFn = jest.fn(); + appStateController.waitForUnlock(resolveFn, true); + + expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(1); + expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + expect.objectContaining({ + id: expect.any(String), + origin: ORIGIN_METAMASK, + type: 'unlock', + }), + true, + ); + }); + }); + + describe('handleUnlock', () => { + beforeEach(() => { + jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('accepts approval request revolving all the related promises', async () => { + const emitSpy = jest.spyOn(appStateController, 'emit'); + const resolveFn = jest.fn(); + appStateController.waitForUnlock(resolveFn, true); + + appStateController.handleUnlock(); + + expect(emitSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith('updateBadge'); + expect(appStateController.messagingSystem.call).toHaveBeenCalled(); + expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + 'ApprovalController:acceptRequest', + expect.any(String), + ); + }); + }); + + describe('setDefaultHomeActiveTabName', () => { + it('sets the default home tab name', () => { + appStateController.setDefaultHomeActiveTabName('testTabName'); + expect(appStateController.store.getState().defaultHomeActiveTabName).toBe( + 'testTabName', + ); + }); + }); + + describe('setConnectedStatusPopoverHasBeenShown', () => { + it('sets connected status popover as shown', () => { + appStateController.setConnectedStatusPopoverHasBeenShown(); + expect( + appStateController.store.getState().connectedStatusPopoverHasBeenShown, + ).toBe(true); + }); + }); + + describe('setRecoveryPhraseReminderHasBeenShown', () => { + it('sets recovery phrase reminder as shown', () => { + appStateController.setRecoveryPhraseReminderHasBeenShown(); + expect( + appStateController.store.getState().recoveryPhraseReminderHasBeenShown, + ).toBe(true); + }); + }); + + describe('setRecoveryPhraseReminderLastShown', () => { + it('sets the last shown time of recovery phrase reminder', () => { + const timestamp = Date.now(); + appStateController.setRecoveryPhraseReminderLastShown(timestamp); + + expect( + appStateController.store.getState().recoveryPhraseReminderLastShown, + ).toBe(timestamp); + }); + }); + + describe('setLastActiveTime', () => { + it('sets the last active time to the current time', () => { + const spy = jest.spyOn(appStateController, '_resetTimer'); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('setBrowserEnvironment', () => { + it('sets the current browser and OS environment', () => { + appStateController.setBrowserEnvironment('Windows', 'Chrome'); + expect( + appStateController.store.getState().browserEnvironment, + ).toStrictEqual({ + os: 'Windows', + browser: 'Chrome', + }); + }); + }); + + describe('addPollingToken', () => { + it('adds a pollingToken for a given environmentType', () => { + const pollingTokenType = 'popupGasPollTokens'; + appStateController.addPollingToken('token1', pollingTokenType); + expect(appStateController.store.getState()[pollingTokenType]).toContain( + 'token1', + ); + }); + }); + + describe('removePollingToken', () => { + it('removes a pollingToken for a given environmentType', () => { + const pollingTokenType = 'popupGasPollTokens'; + appStateController.addPollingToken('token1', pollingTokenType); + appStateController.removePollingToken('token1', pollingTokenType); + expect( + appStateController.store.getState()[pollingTokenType], + ).not.toContain('token1'); + }); + }); + + describe('clearPollingTokens', () => { + it('clears all pollingTokens', () => { + appStateController.addPollingToken('token1', 'popupGasPollTokens'); + appStateController.addPollingToken('token2', 'notificationGasPollTokens'); + appStateController.addPollingToken('token3', 'fullScreenGasPollTokens'); + appStateController.clearPollingTokens(); + + expect( + appStateController.store.getState().popupGasPollTokens, + ).toStrictEqual([]); + expect( + appStateController.store.getState().notificationGasPollTokens, + ).toStrictEqual([]); + expect( + appStateController.store.getState().fullScreenGasPollTokens, + ).toStrictEqual([]); + }); + }); + + describe('setShowTestnetMessageInDropdown', () => { + it('sets whether the testnet dismissal link should be shown in the network dropdown', () => { + appStateController.setShowTestnetMessageInDropdown(true); + expect( + appStateController.store.getState().showTestnetMessageInDropdown, + ).toBe(true); + + appStateController.setShowTestnetMessageInDropdown(false); + expect( + appStateController.store.getState().showTestnetMessageInDropdown, + ).toBe(false); + }); + }); + + describe('setShowBetaHeader', () => { + it('sets whether the beta notification heading on the home page', () => { + appStateController.setShowBetaHeader(true); + expect(appStateController.store.getState().showBetaHeader).toBe(true); + + appStateController.setShowBetaHeader(false); + expect(appStateController.store.getState().showBetaHeader).toBe(false); + }); + }); + + describe('setCurrentPopupId', () => { + it('sets the currentPopupId in the appState', () => { + const popupId = 'popup1'; + + appStateController.setCurrentPopupId(popupId); + expect(appStateController.store.getState().currentPopupId).toBe(popupId); + }); + }); + + describe('getCurrentPopupId', () => { + it('retrieves the currentPopupId saved in the appState', () => { + const popupId = 'popup1'; + + appStateController.setCurrentPopupId(popupId); + expect(appStateController.getCurrentPopupId()).toBe(popupId); + }); + }); + + describe('setFirstTimeUsedNetwork', () => { + it('updates the array of the first time used networks', () => { + const chainId = '0x1'; + + appStateController.setFirstTimeUsedNetwork(chainId); + expect(appStateController.store.getState().usedNetworks[chainId]).toBe( + true, + ); + }); + }); + + describe('setLastInteractedConfirmationInfo', () => { + it('sets information about last confirmation user has interacted with', () => { + const lastInteractedConfirmationInfo = { + id: '123', + chainId: '0x1', + timestamp: new Date().getTime(), + }; + appStateController.setLastInteractedConfirmationInfo( + lastInteractedConfirmationInfo, + ); + expect(appStateController.getLastInteractedConfirmationInfo()).toBe( + lastInteractedConfirmationInfo, + ); + + appStateController.setLastInteractedConfirmationInfo(undefined); + expect(appStateController.getLastInteractedConfirmationInfo()).toBe( + undefined, + ); + }); + }); + + describe('setSnapsInstallPrivacyWarningShownStatus', () => { + it('updates the status of snaps install privacy warning', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setSnapsInstallPrivacyWarningShownStatus(true); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + snapsInstallPrivacyWarningShown: true, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('institutional', () => { + it('set the interactive replacement token with a url and the old refresh token', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = { url: 'https://example.com', oldRefreshToken: 'old' }; + + appStateController.showInteractiveReplacementTokenBanner(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + interactiveReplacementToken: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + + it('set the setCustodianDeepLink with the fromAddress and custodyId', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = { fromAddress: '0x', custodyId: 'custodyId' }; + + appStateController.setCustodianDeepLink(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + custodianDeepLink: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + + it('set the setNoteToTraderMessage with a message', () => { + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = 'some message'; + + appStateController.setNoteToTraderMessage(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + noteToTraderMessage: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); +}); From a317eebc58b3afa16b75cf1902ba215e10cad3c8 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 16 Oct 2024 16:30:33 +0100 Subject: [PATCH 21/24] convert app-state to typescript --- .../{app-state.test.js => app-state-controller.test.ts} | 0 app/scripts/controllers/{app-state.js => app-state-controller.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename app/scripts/controllers/{app-state.test.js => app-state-controller.test.ts} (100%) rename app/scripts/controllers/{app-state.js => app-state-controller.ts} (100%) diff --git a/app/scripts/controllers/app-state.test.js b/app/scripts/controllers/app-state-controller.test.ts similarity index 100% rename from app/scripts/controllers/app-state.test.js rename to app/scripts/controllers/app-state-controller.test.ts diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state-controller.ts similarity index 100% rename from app/scripts/controllers/app-state.js rename to app/scripts/controllers/app-state-controller.ts From dff8439379de65a89a06871f68576f66af6718fb Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Wed, 16 Oct 2024 16:41:43 +0100 Subject: [PATCH 22/24] convert app-state controller to typescript --- .../controllers/app-state-controller.test.ts | 466 +++++++++++++-- .../controllers/app-state-controller.ts | 537 +++++++++++++----- 2 files changed, 787 insertions(+), 216 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 46fe87d29ad..3023f07f732 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -1,53 +1,135 @@ -import { ObservableStore } from '@metamask/obs-store'; -import { ORIGIN_METAMASK } from '../../../shared/constants/app'; -import AppStateController from './app-state'; - -let appStateController, mockStore; +import { + AcceptRequest, + AddApprovalRequest, +} from '@metamask/approval-controller'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; +import { Browser } from 'webextension-polyfill'; +import { + ENVIRONMENT_TYPE_POPUP, + ORIGIN_METAMASK, + POLLING_TOKEN_ENVIRONMENT_TYPES, +} from '../../../shared/constants/app'; +import { AppStateController } from './app-state-controller'; +import type { + AllowedActions, + AllowedEvents, + AppStateControllerActions, + AppStateControllerEvents, + AppStateControllerState, +} from './app-state-controller'; +import { + PreferencesControllerState, + PreferencesControllerStateChangeEvent, +} from './preferences-controller'; + +jest.mock('webextension-polyfill'); + +const mockIsManifestV3 = jest.fn().mockReturnValue(false); +jest.mock('../../../shared/modules/mv3.utils', () => ({ + get isManifestV3() { + return mockIsManifestV3(); + }, +})); + +let appStateController: AppStateController; +let controllerMessenger: ControllerMessenger< + | AppStateControllerActions + | AllowedActions + | AddApprovalRequest + | AcceptRequest, + | AppStateControllerEvents + | AllowedEvents + | PreferencesControllerStateChangeEvent + | KeyringControllerQRKeyringStateChangeEvent +>; + +const extensionMock = { + alarms: { + getAll: jest.fn(() => Promise.resolve([])), + create: jest.fn(), + clear: jest.fn(), + onAlarm: { + addListener: jest.fn(), + }, + }, +} as unknown as jest.Mocked; describe('AppStateController', () => { - mockStore = new ObservableStore(); - const createAppStateController = (initState = {}) => { - return new AppStateController({ + const createAppStateController = ( + initState: Partial = {}, + ): { + appStateController: AppStateController; + controllerMessenger: typeof controllerMessenger; + } => { + controllerMessenger = new ControllerMessenger(); + jest.spyOn(ControllerMessenger.prototype, 'call'); + const appStateMessenger = controllerMessenger.getRestricted({ + name: 'AppStateController', + allowedActions: [ + `ApprovalController:addRequest`, + `ApprovalController:acceptRequest`, + `PreferencesController:getState`, + ], + allowedEvents: [ + `PreferencesController:stateChange`, + `KeyringController:qrKeyringStateChange`, + ], + }); + controllerMessenger.registerActionHandler( + 'PreferencesController:getState', + jest.fn().mockReturnValue({ + preferences: { + autoLockTimeLimit: 0, + }, + }), + ); + controllerMessenger.registerActionHandler( + 'ApprovalController:addRequest', + jest.fn().mockReturnValue({ + catch: jest.fn(), + }), + ); + appStateController = new AppStateController({ addUnlockListener: jest.fn(), isUnlocked: jest.fn(() => true), initState, onInactiveTimeout: jest.fn(), - showUnlockRequest: jest.fn(), - preferencesController: { - state: { - preferences: { - autoLockTimeLimit: 0, - }, - }, - }, - messenger: { - call: jest.fn(() => ({ - catch: jest.fn(), - })), - subscribe: jest.fn(), - }, + messenger: appStateMessenger, + extension: extensionMock, }); + + return { appStateController, controllerMessenger }; + }; + + const createIsUnlockedMock = (isUnlocked: boolean) => { + return jest + .spyOn( + appStateController as unknown as { isUnlocked: () => boolean }, + 'isUnlocked', + ) + .mockReturnValue(isUnlocked); }; beforeEach(() => { - appStateController = createAppStateController({ store: mockStore }); + ({ appStateController } = createAppStateController()); }); describe('setOutdatedBrowserWarningLastShown', () => { it('sets the last shown time', () => { - appStateController = createAppStateController(); - const date = new Date(); + ({ appStateController } = createAppStateController()); + const timestamp: number = Date.now(); - appStateController.setOutdatedBrowserWarningLastShown(date); + appStateController.setOutdatedBrowserWarningLastShown(timestamp); expect( appStateController.store.getState().outdatedBrowserWarningLastShown, - ).toStrictEqual(date); + ).toStrictEqual(timestamp); }); it('sets outdated browser warning last shown timestamp', () => { - const lastShownTimestamp = Date.now(); - appStateController = createAppStateController(); + const lastShownTimestamp: number = Date.now(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -66,10 +148,8 @@ describe('AppStateController', () => { describe('getUnlockPromise', () => { it('waits for unlock if the extension is locked', async () => { - appStateController = createAppStateController(); - const isUnlockedMock = jest - .spyOn(appStateController, 'isUnlocked') - .mockReturnValue(false); + ({ appStateController } = createAppStateController()); + const isUnlockedMock = createIsUnlockedMock(false); const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); appStateController.getUnlockPromise(true); @@ -78,10 +158,8 @@ describe('AppStateController', () => { }); it('resolves immediately if the extension is already unlocked', async () => { - appStateController = createAppStateController(); - const isUnlockedMock = jest - .spyOn(appStateController, 'isUnlocked') - .mockReturnValue(true); + ({ appStateController } = createAppStateController()); + const isUnlockedMock = createIsUnlockedMock(true); await expect( appStateController.getUnlockPromise(false), @@ -94,20 +172,20 @@ describe('AppStateController', () => { describe('waitForUnlock', () => { it('resolves immediately if already unlocked', async () => { const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn = jest.fn(); + const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, false); expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(0); + expect(controllerMessenger.call).toHaveBeenCalledTimes(1); }); it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { - jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + createIsUnlockedMock(false); - const resolveFn = jest.fn(); + const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, true); - expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(1); - expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + expect(controllerMessenger.call).toHaveBeenCalledTimes(2); + expect(controllerMessenger.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -121,22 +199,22 @@ describe('AppStateController', () => { describe('handleUnlock', () => { beforeEach(() => { - jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + createIsUnlockedMock(false); }); afterEach(() => { jest.clearAllMocks(); }); it('accepts approval request revolving all the related promises', async () => { const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn = jest.fn(); + const resolveFn: () => void = jest.fn(); appStateController.waitForUnlock(resolveFn, true); appStateController.handleUnlock(); expect(emitSpy).toHaveBeenCalled(); expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(appStateController.messagingSystem.call).toHaveBeenCalled(); - expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + expect(controllerMessenger.call).toHaveBeenCalled(); + expect(controllerMessenger.call).toHaveBeenCalledWith( 'ApprovalController:acceptRequest', expect.any(String), ); @@ -172,7 +250,7 @@ describe('AppStateController', () => { describe('setRecoveryPhraseReminderLastShown', () => { it('sets the last shown time of recovery phrase reminder', () => { - const timestamp = Date.now(); + const timestamp: number = Date.now(); appStateController.setRecoveryPhraseReminderLastShown(timestamp); expect( @@ -183,7 +261,28 @@ describe('AppStateController', () => { describe('setLastActiveTime', () => { it('sets the last active time to the current time', () => { - const spy = jest.spyOn(appStateController, '_resetTimer'); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + }); + + it('sets the timer if timeoutMinutes is set', () => { + const timeout = Date.now(); + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); appStateController.setLastActiveTime(); expect(spy).toHaveBeenCalled(); @@ -204,7 +303,8 @@ describe('AppStateController', () => { describe('addPollingToken', () => { it('adds a pollingToken for a given environmentType', () => { - const pollingTokenType = 'popupGasPollTokens'; + const pollingTokenType = + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; appStateController.addPollingToken('token1', pollingTokenType); expect(appStateController.store.getState()[pollingTokenType]).toContain( 'token1', @@ -214,7 +314,8 @@ describe('AppStateController', () => { describe('removePollingToken', () => { it('removes a pollingToken for a given environmentType', () => { - const pollingTokenType = 'popupGasPollTokens'; + const pollingTokenType = + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; appStateController.addPollingToken('token1', pollingTokenType); appStateController.removePollingToken('token1', pollingTokenType); expect( @@ -268,7 +369,7 @@ describe('AppStateController', () => { describe('setCurrentPopupId', () => { it('sets the currentPopupId in the appState', () => { - const popupId = 'popup1'; + const popupId = 12345; appStateController.setCurrentPopupId(popupId); expect(appStateController.store.getState().currentPopupId).toBe(popupId); @@ -277,7 +378,7 @@ describe('AppStateController', () => { describe('getCurrentPopupId', () => { it('retrieves the currentPopupId saved in the appState', () => { - const popupId = 'popup1'; + const popupId = 54321; appStateController.setCurrentPopupId(popupId); expect(appStateController.getCurrentPopupId()).toBe(popupId); @@ -318,7 +419,7 @@ describe('AppStateController', () => { describe('setSnapsInstallPrivacyWarningShownStatus', () => { it('updates the status of snaps install privacy warning', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -337,13 +438,16 @@ describe('AppStateController', () => { describe('institutional', () => { it('set the interactive replacement token with a url and the old refresh token', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', ); - const mockParams = { url: 'https://example.com', oldRefreshToken: 'old' }; + const mockParams = { + url: 'https://example.com', + oldRefreshToken: 'old', + }; appStateController.showInteractiveReplacementTokenBanner(mockParams); @@ -356,13 +460,16 @@ describe('AppStateController', () => { }); it('set the setCustodianDeepLink with the fromAddress and custodyId', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', ); - const mockParams = { fromAddress: '0x', custodyId: 'custodyId' }; + const mockParams = { + fromAddress: '0x', + custodyId: 'custodyId', + }; appStateController.setCustodianDeepLink(mockParams); @@ -375,7 +482,7 @@ describe('AppStateController', () => { }); it('set the setNoteToTraderMessage with a message', () => { - appStateController = createAppStateController(); + ({ appStateController } = createAppStateController()); const updateStateSpy = jest.spyOn( appStateController.store, 'updateState', @@ -393,4 +500,245 @@ describe('AppStateController', () => { updateStateSpy.mockRestore(); }); }); + + describe('setSurveyLinkLastClickedOrClosed', () => { + it('set the surveyLinkLastClickedOrClosed time', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setSurveyLinkLastClickedOrClosed(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + surveyLinkLastClickedOrClosed: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setOnboardingDate', () => { + it('set the onboardingDate', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setOnboardingDate(); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setLastViewedUserSurvey', () => { + it('set the lastViewedUserSurvey with id 1', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = 1; + + appStateController.setLastViewedUserSurvey(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + lastViewedUserSurvey: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setNewPrivacyPolicyToastClickedOrClosed', () => { + it('set the newPrivacyPolicyToastClickedOrClosed to true', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setNewPrivacyPolicyToastClickedOrClosed(); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect( + appStateController.store.getState() + .newPrivacyPolicyToastClickedOrClosed, + ).toStrictEqual(true); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setNewPrivacyPolicyToastShownDate', () => { + it('set the newPrivacyPolicyToastShownDate', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setNewPrivacyPolicyToastShownDate(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + newPrivacyPolicyToastShownDate: mockParams, + }); + expect( + appStateController.store.getState().newPrivacyPolicyToastShownDate, + ).toStrictEqual(mockParams); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setTermsOfUseLastAgreed', () => { + it('set the termsOfUseLastAgreed timestamp', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setTermsOfUseLastAgreed(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + termsOfUseLastAgreed: mockParams, + }); + expect( + appStateController.store.getState().termsOfUseLastAgreed, + ).toStrictEqual(mockParams); + + updateStateSpy.mockRestore(); + }); + }); + + describe('onPreferencesStateChange', () => { + it('should update the timeoutMinutes with the autoLockTimeLimit', () => { + ({ appStateController, controllerMessenger } = + createAppStateController()); + const timeout = Date.now(); + + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + + expect(appStateController.store.getState().timeoutMinutes).toStrictEqual( + timeout, + ); + }); + }); + + describe('isManifestV3', () => { + it('creates alarm when isManifestV3 is true', () => { + mockIsManifestV3.mockReturnValue(true); + ({ appStateController } = createAppStateController()); + + const timeout = Date.now(); + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + expect(extensionMock.alarms.clear).toHaveBeenCalled(); + expect(extensionMock.alarms.onAlarm.addListener).toHaveBeenCalled(); + }); + }); + + describe('AppStateController:getState', () => { + it('should return the current state of the property', () => { + expect( + appStateController.store.getState().recoveryPhraseReminderHasBeenShown, + ).toStrictEqual(false); + expect( + controllerMessenger.call('AppStateController:getState') + .recoveryPhraseReminderHasBeenShown, + ).toStrictEqual(false); + }); + }); + + describe('AppStateController:stateChange', () => { + it('subscribers will recieve the state when published', () => { + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(null); + const timeNow = Date.now(); + controllerMessenger.subscribe( + 'AppStateController:stateChange', + (state: Partial) => { + if (typeof state.surveyLinkLastClickedOrClosed === 'number') { + appStateController.setSurveyLinkLastClickedOrClosed( + state.surveyLinkLastClickedOrClosed, + ); + } + }, + ); + + controllerMessenger.publish( + 'AppStateController:stateChange', + { + surveyLinkLastClickedOrClosed: timeNow, + } as unknown as AppStateControllerState, + [], + ); + + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + expect( + controllerMessenger.call('AppStateController:getState') + .surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + }); + + it('state will be published when there is state change', () => { + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(null); + const timeNow = Date.now(); + controllerMessenger.subscribe( + 'AppStateController:stateChange', + (state: Partial) => { + expect(state.surveyLinkLastClickedOrClosed).toStrictEqual(timeNow); + }, + ); + + appStateController.setSurveyLinkLastClickedOrClosed(timeNow); + + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + expect( + controllerMessenger.call('AppStateController:getState') + .surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + }); + }); }); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 9dabf2313e5..e76b8fe3888 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -3,6 +3,14 @@ import { ObservableStore } from '@metamask/obs-store'; import { v4 as uuid } from 'uuid'; import log from 'loglevel'; import { ApprovalType } from '@metamask/controller-utils'; +import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; +import { RestrictedControllerMessenger } from '@metamask/base-controller'; +import { + AcceptRequest, + AddApprovalRequest, +} from '@metamask/approval-controller'; +import { Json } from '@metamask/utils'; +import { Browser } from 'webextension-polyfill'; import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; import { MINUTE } from '../../../shared/constants/time'; import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; @@ -16,20 +24,200 @@ import { ORIGIN_METAMASK, } from '../../../shared/constants/app'; import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; - -/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ - -export default class AppStateController extends EventEmitter { - /** - * @param {object} opts - */ - constructor(opts = {}) { +import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; +import { SecurityAlertResponse } from '../lib/ppom/types'; +import type { + Preferences, + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from './preferences-controller'; + +export type AppStateControllerState = { + timeoutMinutes: number; + connectedStatusPopoverHasBeenShown: boolean; + defaultHomeActiveTabName: string | null; + browserEnvironment: Record; + popupGasPollTokens: string[]; + notificationGasPollTokens: string[]; + fullScreenGasPollTokens: string[]; + recoveryPhraseReminderHasBeenShown: boolean; + recoveryPhraseReminderLastShown: number; + outdatedBrowserWarningLastShown: number | null; + nftsDetectionNoticeDismissed: boolean; + showTestnetMessageInDropdown: boolean; + showBetaHeader: boolean; + showPermissionsTour: boolean; + showNetworkBanner: boolean; + showAccountBanner: boolean; + trezorModel: string | null; + currentPopupId?: number; + onboardingDate: number | null; + lastViewedUserSurvey: number | null; + newPrivacyPolicyToastClickedOrClosed: boolean | null; + newPrivacyPolicyToastShownDate: number | null; + // This key is only used for checking if the user had set advancedGasFee + // prior to Migration 92.3 where we split out the setting to support + // multiple networks. + hadAdvancedGasFeesSetPriorToMigration92_3: boolean; + qrHardware: Json; + nftsDropdownState: Json; + usedNetworks: Record; + surveyLinkLastClickedOrClosed: number | null; + signatureSecurityAlertResponses: Record; + // States used for displaying the changed network toast + switchedNetworkDetails: Record | null; + switchedNetworkNeverShowMessage: boolean; + currentExtensionPopupId: number; + lastInteractedConfirmationInfo?: LastInteractedConfirmationInfo; + termsOfUseLastAgreed?: number; + snapsInstallPrivacyWarningShown?: boolean; + interactiveReplacementToken?: { url: string; oldRefreshToken: string }; + noteToTraderMessage?: string; + custodianDeepLink?: { fromAddress: string; custodyId: string }; +}; + +const controllerName = 'AppStateController'; + +/** + * Returns the state of the {@link AppStateController}. + */ +export type AppStateControllerGetStateAction = { + type: 'AppStateController:getState'; + handler: () => AppStateControllerState; +}; + +/** + * Actions exposed by the {@link AppStateController}. + */ +export type AppStateControllerActions = AppStateControllerGetStateAction; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | AddApprovalRequest + | AcceptRequest + | PreferencesControllerGetStateAction; + +/** + * Event emitted when the state of the {@link AppStateController} changes. + */ +export type AppStateControllerStateChangeEvent = { + type: 'AppStateController:stateChange'; + payload: [AppStateControllerState, []]; +}; + +/** + * Events emitted by {@link AppStateController}. + */ +export type AppStateControllerEvents = AppStateControllerStateChangeEvent; + +/** + * Events that this controller is allowed to subscribe. + */ +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | KeyringControllerQRKeyringStateChangeEvent; + +export type AppStateControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + AppStateControllerActions | AllowedActions, + AppStateControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +type PollingTokenType = + | 'popupGasPollTokens' + | 'notificationGasPollTokens' + | 'fullScreenGasPollTokens'; + +type AppStateControllerInitState = Partial< + Omit< + AppStateControllerState, + | 'qrHardware' + | 'nftsDropdownState' + | 'usedNetworks' + | 'surveyLinkLastClickedOrClosed' + | 'signatureSecurityAlertResponses' + | 'switchedNetworkDetails' + | 'switchedNetworkNeverShowMessage' + | 'currentExtensionPopupId' + > +>; + +type AppStateControllerOptions = { + addUnlockListener: (callback: () => void) => void; + isUnlocked: () => boolean; + initState?: AppStateControllerInitState; + onInactiveTimeout?: () => void; + messenger: AppStateControllerMessenger; + extension: Browser; +}; + +const getDefaultAppStateControllerState = ( + initState?: AppStateControllerInitState, +): AppStateControllerState => ({ + timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, + connectedStatusPopoverHasBeenShown: true, + defaultHomeActiveTabName: null, + browserEnvironment: {}, + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: new Date().getTime(), + outdatedBrowserWarningLastShown: null, + nftsDetectionNoticeDismissed: false, + showTestnetMessageInDropdown: true, + showBetaHeader: isBeta(), + showPermissionsTour: true, + showNetworkBanner: true, + showAccountBanner: true, + trezorModel: null, + onboardingDate: null, + lastViewedUserSurvey: null, + newPrivacyPolicyToastClickedOrClosed: null, + newPrivacyPolicyToastShownDate: null, + hadAdvancedGasFeesSetPriorToMigration92_3: false, + ...initState, + qrHardware: {}, + nftsDropdownState: {}, + usedNetworks: { + '0x1': true, + '0x5': true, + '0x539': true, + }, + surveyLinkLastClickedOrClosed: null, + signatureSecurityAlertResponses: {}, + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage: false, + currentExtensionPopupId: 0, +}); + +export class AppStateController extends EventEmitter { + private readonly extension: AppStateControllerOptions['extension']; + + private readonly onInactiveTimeout: () => void; + + store: ObservableStore; + + private timer: NodeJS.Timeout | null; + + isUnlocked: () => boolean; + + private readonly waitingForUnlock: { resolve: () => void }[]; + + private readonly messagingSystem: AppStateControllerMessenger; + + #approvalRequestId: string | null; + + constructor(opts: AppStateControllerOptions) { const { addUnlockListener, isUnlocked, initState, onInactiveTimeout, - preferencesController, messenger, extension, } = opts; @@ -37,49 +225,9 @@ export default class AppStateController extends EventEmitter { this.extension = extension; this.onInactiveTimeout = onInactiveTimeout || (() => undefined); - this.store = new ObservableStore({ - timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, - connectedStatusPopoverHasBeenShown: true, - defaultHomeActiveTabName: null, - browserEnvironment: {}, - popupGasPollTokens: [], - notificationGasPollTokens: [], - fullScreenGasPollTokens: [], - recoveryPhraseReminderHasBeenShown: false, - recoveryPhraseReminderLastShown: new Date().getTime(), - outdatedBrowserWarningLastShown: null, - nftsDetectionNoticeDismissed: false, - showTestnetMessageInDropdown: true, - showBetaHeader: isBeta(), - showPermissionsTour: true, - showNetworkBanner: true, - showAccountBanner: true, - trezorModel: null, - currentPopupId: undefined, - onboardingDate: null, - lastViewedUserSurvey: null, - newPrivacyPolicyToastClickedOrClosed: null, - newPrivacyPolicyToastShownDate: null, - // This key is only used for checking if the user had set advancedGasFee - // prior to Migration 92.3 where we split out the setting to support - // multiple networks. - hadAdvancedGasFeesSetPriorToMigration92_3: false, - ...initState, - qrHardware: {}, - nftsDropdownState: {}, - usedNetworks: { - '0x1': true, - '0x5': true, - '0x539': true, - }, - surveyLinkLastClickedOrClosed: null, - signatureSecurityAlertResponses: {}, - // States used for displaying the changed network toast - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage: false, - currentExtensionPopupId: 0, - lastInteractedConfirmationInfo: undefined, - }); + this.store = new ObservableStore( + getDefaultAppStateControllerState(initState), + ); this.timer = null; this.isUnlocked = isUnlocked; @@ -88,10 +236,10 @@ export default class AppStateController extends EventEmitter { messenger.subscribe( 'PreferencesController:stateChange', - ({ preferences }) => { + ({ preferences }: { preferences: Partial }) => { const currentState = this.store.getState(); if ( - preferences && + typeof preferences?.autoLockTimeLimit === 'number' && currentState.timeoutMinutes !== preferences.autoLockTimeLimit ) { this._setInactiveTimeout(preferences.autoLockTimeLimit); @@ -101,30 +249,38 @@ export default class AppStateController extends EventEmitter { messenger.subscribe( 'KeyringController:qrKeyringStateChange', - (qrHardware) => + (qrHardware: Json) => this.store.updateState({ qrHardware, }), ); - const { preferences } = preferencesController.state; - - this._setInactiveTimeout(preferences.autoLockTimeLimit); + const { preferences } = messenger.call('PreferencesController:getState'); + if (typeof preferences.autoLockTimeLimit === 'number') { + this._setInactiveTimeout(preferences.autoLockTimeLimit); + } this.messagingSystem = messenger; - this._approvalRequestId = null; + this.messagingSystem.registerActionHandler( + 'AppStateController:getState', + () => this.store.getState(), + ); + this.store.subscribe((state: AppStateControllerState) => { + this.messagingSystem.publish('AppStateController:stateChange', state, []); + }); + this.#approvalRequestId = null; } /** * Get a Promise that resolves when the extension is unlocked. * This Promise will never reject. * - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification + * @param shouldShowUnlockRequest - Whether the extension notification * popup should be opened. - * @returns {Promise} A promise that resolves when the extension is + * @returns A promise that resolves when the extension is * unlocked, or immediately if the extension is already unlocked. */ - getUnlockPromise(shouldShowUnlockRequest) { + getUnlockPromise(shouldShowUnlockRequest: boolean): Promise { return new Promise((resolve) => { if (this.isUnlocked()) { resolve(); @@ -138,12 +294,12 @@ export default class AppStateController extends EventEmitter { * Adds a Promise's resolve function to the waitingForUnlock queue. * Also opens the extension popup if specified. * - * @param {Promise.resolve} resolve - A Promise's resolve function that will + * @param resolve - A Promise's resolve function that will * be called when the extension is unlocked. - * @param {boolean} shouldShowUnlockRequest - Whether the extension notification + * @param shouldShowUnlockRequest - Whether the extension notification * popup should be opened. */ - waitForUnlock(resolve, shouldShowUnlockRequest) { + waitForUnlock(resolve: () => void, shouldShowUnlockRequest: boolean): void { this.waitingForUnlock.push({ resolve }); this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); if (shouldShowUnlockRequest) { @@ -154,10 +310,10 @@ export default class AppStateController extends EventEmitter { /** * Drains the waitingForUnlock queue, resolving all the related Promises. */ - handleUnlock() { + handleUnlock(): void { if (this.waitingForUnlock.length > 0) { while (this.waitingForUnlock.length > 0) { - this.waitingForUnlock.shift().resolve(); + this.waitingForUnlock.shift()?.resolve(); } this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); } @@ -168,9 +324,9 @@ export default class AppStateController extends EventEmitter { /** * Sets the default home tab * - * @param {string} [defaultHomeActiveTabName] - the tab name + * @param defaultHomeActiveTabName - the tab name */ - setDefaultHomeActiveTabName(defaultHomeActiveTabName) { + setDefaultHomeActiveTabName(defaultHomeActiveTabName: string | null): void { this.store.updateState({ defaultHomeActiveTabName, }); @@ -179,7 +335,7 @@ export default class AppStateController extends EventEmitter { /** * Record that the user has seen the connected status info popover */ - setConnectedStatusPopoverHasBeenShown() { + setConnectedStatusPopoverHasBeenShown(): void { this.store.updateState({ connectedStatusPopoverHasBeenShown: true, }); @@ -188,37 +344,37 @@ export default class AppStateController extends EventEmitter { /** * Record that the user has been shown the recovery phrase reminder. */ - setRecoveryPhraseReminderHasBeenShown() { + setRecoveryPhraseReminderHasBeenShown(): void { this.store.updateState({ recoveryPhraseReminderHasBeenShown: true, }); } - setSurveyLinkLastClickedOrClosed(time) { + setSurveyLinkLastClickedOrClosed(time: number): void { this.store.updateState({ surveyLinkLastClickedOrClosed: time, }); } - setOnboardingDate() { + setOnboardingDate(): void { this.store.updateState({ onboardingDate: Date.now(), }); } - setLastViewedUserSurvey(id) { + setLastViewedUserSurvey(id: number) { this.store.updateState({ lastViewedUserSurvey: id, }); } - setNewPrivacyPolicyToastClickedOrClosed() { + setNewPrivacyPolicyToastClickedOrClosed(): void { this.store.updateState({ newPrivacyPolicyToastClickedOrClosed: true, }); } - setNewPrivacyPolicyToastShownDate(time) { + setNewPrivacyPolicyToastShownDate(time: number): void { this.store.updateState({ newPrivacyPolicyToastShownDate: time, }); @@ -227,9 +383,9 @@ export default class AppStateController extends EventEmitter { /** * Record the timestamp of the last time the user has seen the recovery phrase reminder * - * @param {number} lastShown - timestamp when user was last shown the reminder. + * @param lastShown - timestamp when user was last shown the reminder. */ - setRecoveryPhraseReminderLastShown(lastShown) { + setRecoveryPhraseReminderLastShown(lastShown: number): void { this.store.updateState({ recoveryPhraseReminderLastShown: lastShown, }); @@ -238,9 +394,9 @@ export default class AppStateController extends EventEmitter { /** * Record the timestamp of the last time the user has acceoted the terms of use * - * @param {number} lastAgreed - timestamp when user last accepted the terms of use + * @param lastAgreed - timestamp when user last accepted the terms of use */ - setTermsOfUseLastAgreed(lastAgreed) { + setTermsOfUseLastAgreed(lastAgreed: number): void { this.store.updateState({ termsOfUseLastAgreed: lastAgreed, }); @@ -250,9 +406,9 @@ export default class AppStateController extends EventEmitter { * Record if popover for snaps privacy warning has been shown * on the first install of a snap. * - * @param {boolean} shown - shown status + * @param shown - shown status */ - setSnapsInstallPrivacyWarningShownStatus(shown) { + setSnapsInstallPrivacyWarningShownStatus(shown: boolean): void { this.store.updateState({ snapsInstallPrivacyWarningShown: shown, }); @@ -261,9 +417,9 @@ export default class AppStateController extends EventEmitter { /** * Record the timestamp of the last time the user has seen the outdated browser warning * - * @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. + * @param lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. */ - setOutdatedBrowserWarningLastShown(lastShown) { + setOutdatedBrowserWarningLastShown(lastShown: number): void { this.store.updateState({ outdatedBrowserWarningLastShown: lastShown, }); @@ -272,17 +428,16 @@ export default class AppStateController extends EventEmitter { /** * Sets the last active time to the current time. */ - setLastActiveTime() { + setLastActiveTime(): void { this._resetTimer(); } /** * Sets the inactive timeout for the app * - * @private - * @param {number} timeoutMinutes - The inactive timeout in minutes. + * @param timeoutMinutes - The inactive timeout in minutes. */ - _setInactiveTimeout(timeoutMinutes) { + private _setInactiveTimeout(timeoutMinutes: number): void { this.store.updateState({ timeoutMinutes, }); @@ -296,10 +451,8 @@ export default class AppStateController extends EventEmitter { * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new * timer will not be created. * - * @private */ - /* eslint-disable no-undef */ - _resetTimer() { + private _resetTimer(): void { const { timeoutMinutes } = this.store.getState(); if (this.timer) { @@ -326,12 +479,14 @@ export default class AppStateController extends EventEmitter { delayInMinutes: timeoutToSet, periodInMinutes: timeoutToSet, }); - this.extension.alarms.onAlarm.addListener((alarmInfo) => { - if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - }); + this.extension.alarms.onAlarm.addListener( + (alarmInfo: { name: string }) => { + if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { + this.onInactiveTimeout(); + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + }, + ); } else { this.timer = setTimeout( () => this.onInactiveTimeout(), @@ -346,7 +501,7 @@ export default class AppStateController extends EventEmitter { * @param os * @param browser */ - setBrowserEnvironment(os, browser) { + setBrowserEnvironment(os: string, browser: string): void { this.store.updateState({ browserEnvironment: { os, browser } }); } @@ -356,40 +511,81 @@ export default class AppStateController extends EventEmitter { * @param pollingToken * @param pollingTokenType */ - addPollingToken(pollingToken, pollingTokenType) { + addPollingToken( + pollingToken: string, + pollingTokenType: PollingTokenType, + ): void { if ( - pollingTokenType !== + pollingTokenType.toString() !== POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: [...prevState, pollingToken], - }); + if (this.#isValidPollingTokenType(pollingTokenType)) { + this.#updatePollingTokens(pollingToken, pollingTokenType); + } } } + /** + * Updates the polling token in the state. + * + * @param pollingToken + * @param pollingTokenType + */ + #updatePollingTokens( + pollingToken: string, + pollingTokenType: PollingTokenType, + ) { + const currentTokens: string[] = this.store.getState()[pollingTokenType]; + this.store.updateState({ + [pollingTokenType]: [...currentTokens, pollingToken], + }); + } + /** * removes a pollingToken for a given environmentType * * @param pollingToken * @param pollingTokenType */ - removePollingToken(pollingToken, pollingTokenType) { + removePollingToken( + pollingToken: string, + pollingTokenType: PollingTokenType, + ): void { if ( - pollingTokenType !== + pollingTokenType.toString() !== POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] ) { - const prevState = this.store.getState()[pollingTokenType]; - this.store.updateState({ - [pollingTokenType]: prevState.filter((token) => token !== pollingToken), - }); + const currentTokens: string[] = this.store.getState()[pollingTokenType]; + if (this.#isValidPollingTokenType(pollingTokenType)) { + this.store.updateState({ + [pollingTokenType]: currentTokens.filter( + (token: string) => token !== pollingToken, + ), + }); + } } } + /** + * Validates whether the given polling token type is a valid one. + * + * @param pollingTokenType + * @returns true if valid, false otherwise. + */ + #isValidPollingTokenType(pollingTokenType: PollingTokenType): boolean { + const validTokenTypes: PollingTokenType[] = [ + 'popupGasPollTokens', + 'notificationGasPollTokens', + 'fullScreenGasPollTokens', + ]; + + return validTokenTypes.includes(pollingTokenType); + } + /** * clears all pollingTokens */ - clearPollingTokens() { + clearPollingTokens(): void { this.store.updateState({ popupGasPollTokens: [], notificationGasPollTokens: [], @@ -402,7 +598,7 @@ export default class AppStateController extends EventEmitter { * * @param showTestnetMessageInDropdown */ - setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { + setShowTestnetMessageInDropdown(showTestnetMessageInDropdown: boolean): void { this.store.updateState({ showTestnetMessageInDropdown }); } @@ -411,7 +607,7 @@ export default class AppStateController extends EventEmitter { * * @param showBetaHeader */ - setShowBetaHeader(showBetaHeader) { + setShowBetaHeader(showBetaHeader: boolean): void { this.store.updateState({ showBetaHeader }); } @@ -420,7 +616,7 @@ export default class AppStateController extends EventEmitter { * * @param showPermissionsTour */ - setShowPermissionsTour(showPermissionsTour) { + setShowPermissionsTour(showPermissionsTour: boolean): void { this.store.updateState({ showPermissionsTour }); } @@ -429,7 +625,7 @@ export default class AppStateController extends EventEmitter { * * @param showNetworkBanner */ - setShowNetworkBanner(showNetworkBanner) { + setShowNetworkBanner(showNetworkBanner: boolean): void { this.store.updateState({ showNetworkBanner }); } @@ -438,7 +634,7 @@ export default class AppStateController extends EventEmitter { * * @param showAccountBanner */ - setShowAccountBanner(showAccountBanner) { + setShowAccountBanner(showAccountBanner: boolean): void { this.store.updateState({ showAccountBanner }); } @@ -447,7 +643,7 @@ export default class AppStateController extends EventEmitter { * * @param currentExtensionPopupId */ - setCurrentExtensionPopupId(currentExtensionPopupId) { + setCurrentExtensionPopupId(currentExtensionPopupId: number): void { this.store.updateState({ currentExtensionPopupId }); } @@ -455,16 +651,18 @@ export default class AppStateController extends EventEmitter { * Sets an object with networkName and appName * or `null` if the message is meant to be cleared * - * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. + * @param switchedNetworkDetails - Details about the network that MetaMask just switched to. */ - setSwitchedNetworkDetails(switchedNetworkDetails) { + setSwitchedNetworkDetails( + switchedNetworkDetails: { origin: string; networkClientId: string } | null, + ): void { this.store.updateState({ switchedNetworkDetails }); } /** * Clears the switched network details in state */ - clearSwitchedNetworkDetails() { + clearSwitchedNetworkDetails(): void { this.store.updateState({ switchedNetworkDetails: null }); } @@ -472,9 +670,11 @@ export default class AppStateController extends EventEmitter { * Remembers if the user prefers to never see the * network switched message again * - * @param {boolean} switchedNetworkNeverShowMessage + * @param switchedNetworkNeverShowMessage */ - setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { + setSwitchedNetworkNeverShowMessage( + switchedNetworkNeverShowMessage: boolean, + ): void { this.store.updateState({ switchedNetworkDetails: null, switchedNetworkNeverShowMessage, @@ -486,7 +686,7 @@ export default class AppStateController extends EventEmitter { * * @param trezorModel - The Trezor model. */ - setTrezorModel(trezorModel) { + setTrezorModel(trezorModel: string | null): void { this.store.updateState({ trezorModel }); } @@ -495,7 +695,7 @@ export default class AppStateController extends EventEmitter { * * @param nftsDropdownState */ - updateNftDropDownState(nftsDropdownState) { + updateNftDropDownState(nftsDropdownState: Json): void { this.store.updateState({ nftsDropdownState, }); @@ -505,9 +705,8 @@ export default class AppStateController extends EventEmitter { * Updates the array of the first time used networks * * @param chainId - * @returns {void} */ - setFirstTimeUsedNetwork(chainId) { + setFirstTimeUsedNetwork(chainId: string): void { const currentState = this.store.getState(); const { usedNetworks } = currentState; usedNetworks[chainId] = true; @@ -519,12 +718,17 @@ export default class AppStateController extends EventEmitter { /** * Set the interactive replacement token with a url and the old refresh token * - * @param {object} opts + * @param opts * @param opts.url * @param opts.oldRefreshToken - * @returns {void} */ - showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) { + showInteractiveReplacementTokenBanner({ + url, + oldRefreshToken, + }: { + url: string; + oldRefreshToken: string; + }): void { this.store.updateState({ interactiveReplacementToken: { url, @@ -536,18 +740,23 @@ export default class AppStateController extends EventEmitter { /** * Set the setCustodianDeepLink with the fromAddress and custodyId * - * @param {object} opts + * @param opts * @param opts.fromAddress * @param opts.custodyId - * @returns {void} */ - setCustodianDeepLink({ fromAddress, custodyId }) { + setCustodianDeepLink({ + fromAddress, + custodyId, + }: { + fromAddress: string; + custodyId: string; + }): void { this.store.updateState({ custodianDeepLink: { fromAddress, custodyId }, }); } - setNoteToTraderMessage(message) { + setNoteToTraderMessage(message: string): void { this.store.updateState({ noteToTraderMessage: message, }); @@ -555,21 +764,28 @@ export default class AppStateController extends EventEmitter { ///: END:ONLY_INCLUDE_IF - getSignatureSecurityAlertResponse(securityAlertId) { + getSignatureSecurityAlertResponse( + securityAlertId: string, + ): SecurityAlertResponse { return this.store.getState().signatureSecurityAlertResponses[ securityAlertId ]; } - addSignatureSecurityAlertResponse(securityAlertResponse) { + addSignatureSecurityAlertResponse( + securityAlertResponse: SecurityAlertResponse, + ): void { const currentState = this.store.getState(); const { signatureSecurityAlertResponses } = currentState; - this.store.updateState({ - signatureSecurityAlertResponses: { - ...signatureSecurityAlertResponses, - [securityAlertResponse.securityAlertId]: securityAlertResponse, - }, - }); + if (securityAlertResponse.securityAlertId) { + this.store.updateState({ + signatureSecurityAlertResponses: { + ...signatureSecurityAlertResponses, + [String(securityAlertResponse.securityAlertId)]: + securityAlertResponse, + }, + }); + } } /** @@ -577,7 +793,7 @@ export default class AppStateController extends EventEmitter { * * @param currentPopupId */ - setCurrentPopupId(currentPopupId) { + setCurrentPopupId(currentPopupId: number): void { this.store.updateState({ currentPopupId, }); @@ -585,19 +801,21 @@ export default class AppStateController extends EventEmitter { /** * The function returns information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. */ - getLastInteractedConfirmationInfo() { + getLastInteractedConfirmationInfo(): + | LastInteractedConfirmationInfo + | undefined { return this.store.getState().lastInteractedConfirmationInfo; } /** * Update the information about the last confirmation user interacted with * - * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. + * @param lastInteractedConfirmationInfo */ - setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { + setLastInteractedConfirmationInfo( + lastInteractedConfirmationInfo: LastInteractedConfirmationInfo | undefined, + ): void { this.store.updateState({ lastInteractedConfirmationInfo, }); @@ -606,22 +824,22 @@ export default class AppStateController extends EventEmitter { /** * A getter to retrieve currentPopupId saved in the appState */ - getCurrentPopupId() { + getCurrentPopupId(): number | undefined { return this.store.getState().currentPopupId; } - _requestApproval() { + private _requestApproval(): void { // If we already have a pending request this is a no-op - if (this._approvalRequestId) { + if (this.#approvalRequestId) { return; } - this._approvalRequestId = uuid(); + this.#approvalRequestId = uuid(); this.messagingSystem .call( 'ApprovalController:addRequest', { - id: this._approvalRequestId, + id: this.#approvalRequestId, origin: ORIGIN_METAMASK, type: ApprovalType.Unlock, }, @@ -629,23 +847,28 @@ export default class AppStateController extends EventEmitter { ) .catch(() => { // If the promise fails, we allow a new popup to be triggered - this._approvalRequestId = null; + this.#approvalRequestId = null; }); } - _acceptApproval() { - if (!this._approvalRequestId) { + // Override emit method to provide strong typing for events + emit(event: string) { + return super.emit(event); + } + + private _acceptApproval(): void { + if (!this.#approvalRequestId) { return; } try { this.messagingSystem.call( 'ApprovalController:acceptRequest', - this._approvalRequestId, + this.#approvalRequestId, ); } catch (error) { log.error('Failed to unlock approval request', error); } - this._approvalRequestId = null; + this.#approvalRequestId = null; } } From a958732a6c3853c231e2ac69c61647dfe02acfb4 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Thu, 17 Oct 2024 11:58:01 +0100 Subject: [PATCH 23/24] revert unused preferences controller removal from mmi-controller --- app/scripts/controllers/mmi-controller.ts | 4 ++++ app/scripts/metamask-controller.js | 1 + 2 files changed, 5 insertions(+) diff --git a/app/scripts/controllers/mmi-controller.ts b/app/scripts/controllers/mmi-controller.ts index 20f14c47304..3e925c4a1b0 100644 --- a/app/scripts/controllers/mmi-controller.ts +++ b/app/scripts/controllers/mmi-controller.ts @@ -46,6 +46,7 @@ import MetaMetricsController from './metametrics'; import { getPermissionBackgroundApiMethods } from './permissions'; import AccountTrackerController from './account-tracker-controller'; import { AppStateController } from './app-state-controller'; +import { PreferencesController } from './preferences-controller'; type UpdateCustodianTransactionsParameters = { keyring: CustodyKeyring; @@ -69,6 +70,8 @@ export default class MMIController extends EventEmitter { // eslint-disable-next-line @typescript-eslint/no-explicit-any public keyringController: any; + public preferencesController: PreferencesController; + public appStateController: AppStateController; public transactionUpdateController: TransactionUpdateController; @@ -139,6 +142,7 @@ export default class MMIController extends EventEmitter { this.messenger = opts.messenger; this.mmiConfigurationController = opts.mmiConfigurationController; this.keyringController = opts.keyringController; + this.preferencesController = opts.preferencesController; this.appStateController = opts.appStateController; this.transactionUpdateController = opts.transactionUpdateController; this.custodyController = opts.custodyController; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 6a61239fde7..dd5f97f3ebf 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2013,6 +2013,7 @@ export default class MetamaskController extends EventEmitter { messenger: mmiControllerMessenger, mmiConfigurationController: this.mmiConfigurationController, keyringController: this.keyringController, + preferencesController: this.preferencesController, appStateController: this.appStateController, transactionUpdateController: this.transactionUpdateController, custodyController: this.custodyController, From 2e86cfe33dd0e12df9ee6bed72fda8f439db3659 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Fri, 18 Oct 2024 12:26:39 +0100 Subject: [PATCH 24/24] remove not needed actions and events --- .../controllers/app-state-controller.test.ts | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 3023f07f732..740c4a7d33f 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -1,9 +1,4 @@ -import { - AcceptRequest, - AddApprovalRequest, -} from '@metamask/approval-controller'; import { ControllerMessenger } from '@metamask/base-controller'; -import { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; import { Browser } from 'webextension-polyfill'; import { ENVIRONMENT_TYPE_POPUP, @@ -18,10 +13,7 @@ import type { AppStateControllerEvents, AppStateControllerState, } from './app-state-controller'; -import { - PreferencesControllerState, - PreferencesControllerStateChangeEvent, -} from './preferences-controller'; +import { PreferencesControllerState } from './preferences-controller'; jest.mock('webextension-polyfill'); @@ -34,14 +26,8 @@ jest.mock('../../../shared/modules/mv3.utils', () => ({ let appStateController: AppStateController; let controllerMessenger: ControllerMessenger< - | AppStateControllerActions - | AllowedActions - | AddApprovalRequest - | AcceptRequest, - | AppStateControllerEvents - | AllowedEvents - | PreferencesControllerStateChangeEvent - | KeyringControllerQRKeyringStateChangeEvent + AppStateControllerActions | AllowedActions, + AppStateControllerEvents | AllowedEvents >; const extensionMock = {