diff --git a/packages/examples/packages/lifecycle-hooks/README.md b/packages/examples/packages/lifecycle-hooks/README.md index a740b83472..58e1631705 100644 --- a/packages/examples/packages/lifecycle-hooks/README.md +++ b/packages/examples/packages/lifecycle-hooks/README.md @@ -1,7 +1,7 @@ # `@metamask/lifecylce-hooks-example-snap` -This snap demonstrates how to use the `onInstall` and `onUpdate` lifecycle -hooks. +This Snap demonstrates how to use the `onStart`, `onInstall`, and `onUpdate` +lifecycle hooks. ## Snap manifest @@ -22,9 +22,9 @@ Along with other permissions, the manifest of this snap includes the ## Snap usage -This snap exposes the `onInstall` and `onUpdate` lifecycle hooks. These hooks -are called when the snap is installed or updated, respectively, and cannot be -called manually. +This Snap exposes the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks. +These hooks are called when the client is started, when the Snap is installed, +or the Snap is updated, respectively, and cannot be called manually. For more information, you can refer to [the end-to-end tests](./src/index.test.ts). diff --git a/packages/examples/packages/lifecycle-hooks/package.json b/packages/examples/packages/lifecycle-hooks/package.json index fd12cf32bc..ca5e47f023 100644 --- a/packages/examples/packages/lifecycle-hooks/package.json +++ b/packages/examples/packages/lifecycle-hooks/package.json @@ -1,7 +1,7 @@ { "name": "@metamask/lifecycle-hooks-example-snap", "version": "2.1.3", - "description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks", + "description": "MetaMask example snap demonstrating the use of the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks", "keywords": [ "MetaMask", "Snaps", diff --git a/packages/examples/packages/lifecycle-hooks/snap.manifest.json b/packages/examples/packages/lifecycle-hooks/snap.manifest.json index 64db943321..6183e6b835 100644 --- a/packages/examples/packages/lifecycle-hooks/snap.manifest.json +++ b/packages/examples/packages/lifecycle-hooks/snap.manifest.json @@ -1,13 +1,13 @@ { "version": "2.1.3", - "description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks.", + "description": "MetaMask example snap demonstrating the use of the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks.", "proposedName": "Lifecycle Hooks Example Snap", "repository": { "type": "git", "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "q+mPr3Gq/2PZDIrqKu+wR7sh1hwKk+F5OoEo1ye+30Q=", + "shasum": "c6HavArVNGdBQlFYdVeKXpahvU0DHpB6D6UupoexRR0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/lifecycle-hooks/src/index.test.tsx b/packages/examples/packages/lifecycle-hooks/src/index.test.tsx index 47433b59ab..639a3c0460 100644 --- a/packages/examples/packages/lifecycle-hooks/src/index.test.tsx +++ b/packages/examples/packages/lifecycle-hooks/src/index.test.tsx @@ -2,6 +2,30 @@ import { describe, expect, it } from '@jest/globals'; import { assertIsAlertDialog, installSnap } from '@metamask/snaps-jest'; import { Box, Text } from '@metamask/snaps-sdk/jsx'; +describe('onStart', () => { + it('shows dialog when the client is started', async () => { + const { onStart } = await installSnap(); + + const response = onStart(); + + const screen = await response.getInterface(); + assertIsAlertDialog(screen); + + expect(screen).toRender( + + + The client was started successfully, and the "onStart" handler was + called. + + , + ); + + await screen.ok(); + + expect(await response).toRespondWith(null); + }); +}); + describe('onInstall', () => { it('shows dialog when the snap is installed', async () => { const { onInstall } = await installSnap(); diff --git a/packages/examples/packages/lifecycle-hooks/src/index.tsx b/packages/examples/packages/lifecycle-hooks/src/index.tsx index b429cc701a..2051992a03 100644 --- a/packages/examples/packages/lifecycle-hooks/src/index.tsx +++ b/packages/examples/packages/lifecycle-hooks/src/index.tsx @@ -1,6 +1,37 @@ -import type { OnInstallHandler, OnUpdateHandler } from '@metamask/snaps-sdk'; +import type { + OnInstallHandler, + OnStartHandler, + OnUpdateHandler, +} from '@metamask/snaps-sdk'; import { Box, Text } from '@metamask/snaps-sdk/jsx'; +/** + * Handle starting of the client. This handler is called when the client is + * started, and can be used to perform any initialization that is required. + * + * This handler is optional. If it is not provided, the Snap will be started + * as usual. + * + * @see https://docs.metamask.io/snaps/reference/entry-points/#onstart + * @returns The JSON-RPC response. + */ +export const onStart: OnStartHandler = async () => { + return await snap.request({ + method: 'snap_dialog', + params: { + type: 'alert', + content: ( + + + The client was started successfully, and the "onStart" handler was + called. + + + ), + }, + }); +}; + /** * Handle installation of the snap. This handler is called when the snap is * installed, and can be used to perform any initialization that is required.' @@ -8,7 +39,7 @@ import { Box, Text } from '@metamask/snaps-sdk/jsx'; * This handler is optional. If it is not provided, the snap will be installed * as usual. * - * @see https://docs.metamask.io/snaps/reference/exports/#oninstall + * @see https://docs.metamask.io/snaps/reference/entry-points/#oninstall * @returns The JSON-RPC response. */ export const onInstall: OnInstallHandler = async () => { @@ -35,7 +66,7 @@ export const onInstall: OnInstallHandler = async () => { * This handler is optional. If it is not provided, the snap will be updated * as usual. * - * @see https://docs.metamask.io/snaps/reference/exports/#onupdate + * @see https://docs.metamask.io/snaps/reference/entry-points/#onupdate * @returns The JSON-RPC response. */ export const onUpdate: OnUpdateHandler = async () => { diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 287f895bda..6d89f22e62 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 95.21, - "functions": 98.71, + "branches": 95.22, + "functions": 98.72, "lines": 98.87, - "statements": 98.7 + "statements": 98.71 } diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 9d13b59bef..2a031d830d 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -9889,6 +9889,127 @@ describe('SnapController', () => { }); describe('SnapController actions', () => { + describe('SnapController:init', () => { + it('calls `onStart` for all Snaps with the `endowment:lifecycle-hooks` permission', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + (origin) => { + if (origin === MOCK_SNAP_ID) { + return { + [SnapEndowments.LifecycleHooks]: + MOCK_LIFECYCLE_HOOKS_PERMISSION, + }; + } + + return {}; + }, + ); + + rootMessenger.registerActionHandler( + 'PermissionController:hasPermission', + (origin) => { + return origin === MOCK_SNAP_ID; + }, + ); + + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ + id: MOCK_SNAP_ID, + }), + getPersistedSnapObject({ + id: MOCK_LOCAL_SNAP_ID, + }), + ), + }, + }), + ); + + const call = jest.spyOn(messenger, 'call'); + messenger.call('SnapController:init'); + await sleep(10); + + expect(call).toHaveBeenNthCalledWith( + 2, + 'PermissionController:hasPermission', + MOCK_SNAP_ID, + 'endowment:lifecycle-hooks', + ); + + expect(call).toHaveBeenNthCalledWith( + 6, + 'ExecutionService:executeSnap', + expect.any(Object), + ); + + expect(messenger.call).toHaveBeenNthCalledWith( + 7, + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + handler: HandlerType.OnStart, + origin: METAMASK_ORIGIN, + request: { + jsonrpc: '2.0', + id: expect.any(String), + method: HandlerType.OnStart, + }, + }, + ); + + snapController.destroy(); + }); + + it('logs an error if the lifecycle hook throws', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.LifecycleHooks]: MOCK_LIFECYCLE_HOOKS_PERMISSION, + }; + }, + ); + + rootMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + () => { + throw new Error('Test error in lifecycle hook.'); + }, + ); + + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + messenger.call('SnapController:init'); + await sleep(10); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error when calling \`onStart\` lifecycle hook for Snap "npm:@metamask/example-snap": Test error in lifecycle hook.`, + ); + + snapController.destroy(); + }); + }); + describe('SnapController:get', () => { it('gets a snap', () => { const messenger = getSnapControllerMessenger(); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index f0ac1fc1a4..a9ceec5ac4 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -338,6 +338,15 @@ type PendingApproval = { // Controller Messenger Actions +/** + * Initialise the SnapController. This should be called after all controllers + * are created. + */ +export type SnapControllerInitAction = { + type: `${typeof controllerName}:init`; + handler: SnapController['init']; +}; + /** * Gets the specified Snap from state. */ @@ -470,6 +479,7 @@ export type SnapControllerGetStateAction = ControllerGetStateAction< >; export type SnapControllerActions = + | SnapControllerInitAction | ClearSnapState | GetSnap | GetSnapState @@ -1160,6 +1170,11 @@ export class SnapController extends BaseController< * actions. */ #registerMessageHandlers(): void { + this.messagingSystem.registerActionHandler( + `${controllerName}:init`, + (...args) => this.init(...args), + ); + this.messagingSystem.registerActionHandler( `${controllerName}:clearSnapState`, (...args) => this.clearSnapState(...args), @@ -1266,6 +1281,37 @@ export class SnapController extends BaseController< ); } + /** + * Initialise the SnapController. + * + * Currently this method calls the `onStart` lifecycle hook for all + * installed Snaps. + */ + init() { + const snaps = this.getRunnableSnaps(); + for (const { id } of snaps) { + const hasLifecycleHooksEndowment = this.messagingSystem.call( + 'PermissionController:hasPermission', + id, + SnapEndowments.LifecycleHooks, + ); + + if (!hasLifecycleHooksEndowment) { + continue; + } + + this.#callLifecycleHook(METAMASK_ORIGIN, id, HandlerType.OnStart).catch( + (error) => { + logError( + `Error when calling \`onStart\` lifecycle hook for Snap "${id}": ${getErrorMessage( + error, + )}`, + ); + }, + ); + } + } + #handlePreinstalledSnaps(preinstalledSnaps: PreinstalledSnap[]) { for (const { snapId, diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json index bc6a469604..e4e7ddfae4 100644 --- a/packages/snaps-execution-environments/coverage.json +++ b/packages/snaps-execution-environments/coverage.json @@ -1,6 +1,6 @@ { - "branches": 90, + "branches": 90.04, "functions": 94.69, - "lines": 90.41, + "lines": 90.42, "statements": 89.62 } diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts index 32d17c0af1..b81728db3b 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -1896,7 +1896,11 @@ describe('BaseSnapExecutor', () => { }); describe('lifecycle hooks', () => { - const LIFECYCLE_HOOKS = [HandlerType.OnInstall, HandlerType.OnUpdate]; + const LIFECYCLE_HOOKS = [ + HandlerType.OnInstall, + HandlerType.OnUpdate, + HandlerType.OnStart, + ]; for (const handler of LIFECYCLE_HOOKS) { it(`supports \`${handler}\` export`, async () => { diff --git a/packages/snaps-execution-environments/src/common/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts index 68588a43f6..bc1c574248 100644 --- a/packages/snaps-execution-environments/src/common/commands.ts +++ b/packages/snaps-execution-environments/src/common/commands.ts @@ -120,6 +120,7 @@ export function getHandlerArguments( case HandlerType.OnInstall: case HandlerType.OnUpdate: + case HandlerType.OnStart: return { origin }; case HandlerType.OnHomePage: diff --git a/packages/snaps-jest/src/helpers.ts b/packages/snaps-jest/src/helpers.ts index 2300147be5..42d8cfe27c 100644 --- a/packages/snaps-jest/src/helpers.ts +++ b/packages/snaps-jest/src/helpers.ts @@ -187,6 +187,7 @@ export async function installSnap< onKeyringRequest, onInstall, onUpdate, + onStart, onNameLookup, onProtocolRequest, onClientRequest, @@ -208,6 +209,7 @@ export async function installSnap< onKeyringRequest, onInstall, onUpdate, + onStart, onNameLookup, onProtocolRequest, onClientRequest, diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index 5730bea959..c656c73a9e 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -121,6 +121,7 @@ export const handlerEndowments: Record = { [HandlerType.OnNameLookup]: nameLookupEndowmentBuilder.targetName, [HandlerType.OnInstall]: lifecycleHooksEndowmentBuilder.targetName, [HandlerType.OnUpdate]: lifecycleHooksEndowmentBuilder.targetName, + [HandlerType.OnStart]: lifecycleHooksEndowmentBuilder.targetName, [HandlerType.OnKeyringRequest]: keyringEndowmentBuilder.targetName, [HandlerType.OnHomePage]: homePageEndowmentBuilder.targetName, [HandlerType.OnSettingsPage]: settingsPageEndowmentBuilder.targetName, diff --git a/packages/snaps-sdk/src/types/handlers/lifecycle.ts b/packages/snaps-sdk/src/types/handlers/lifecycle.ts index 80585a3fc5..f047bd013b 100644 --- a/packages/snaps-sdk/src/types/handlers/lifecycle.ts +++ b/packages/snaps-sdk/src/types/handlers/lifecycle.ts @@ -37,3 +37,16 @@ export type OnInstallHandler = LifecycleEventHandler; * @param args.origin - The origin that triggered the lifecycle event hook. */ export type OnUpdateHandler = LifecycleEventHandler; + +/** + * The `onStart` handler. This is called when the client is started. + * + * Note that using this handler requires the `endowment:lifecycle-hooks` + * permission. + * + * This type is an alias for {@link LifecycleEventHandler}. + * + * @param args - The request arguments. + * @param args.origin - The origin that triggered the lifecycle event hook. + */ +export type OnStartHandler = LifecycleEventHandler; diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx index 74dcb839ca..7a88596093 100644 --- a/packages/snaps-simulation/src/helpers.test.tsx +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -701,6 +701,32 @@ describe('helpers', () => { }); }); + describe('onStart', () => { + it('sends a onStart request and returns the result', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onStart = async () => { + return { content: { type: 'text', value: 'Hello, world!' } }; + }; + `, + }); + + const { onStart, close } = await installSnap(snapId); + const response = await onStart(); + + expect(response).toStrictEqual( + expect.objectContaining({ + getInterface: expect.any(Function), + }), + ); + + await close(); + await closeServer(); + }); + }); + describe('onProtocolRequest', () => { it('sends a onProtocolRequest request and returns the result', async () => { jest.spyOn(console, 'log').mockImplementation(); diff --git a/packages/snaps-simulation/src/helpers.ts b/packages/snaps-simulation/src/helpers.ts index 889e2ba3f8..fc976ff60c 100644 --- a/packages/snaps-simulation/src/helpers.ts +++ b/packages/snaps-simulation/src/helpers.ts @@ -158,6 +158,13 @@ export type SnapHelpers = { */ onUpdate(request?: Pick): SnapRequest; + /** + * Get the response from the Snap's `onStart` handler. + * + * @returns The response. + */ + onStart(request?: Pick): SnapRequest; + /** * Get the response from the Snap's `onNameLookup` handler. * @@ -368,6 +375,25 @@ export function getHelpers({ }); }, + // This can't be async because it returns a `SnapRequest`. + // eslint-disable-next-line @typescript-eslint/promise-function-async + onStart: (request?: Pick) => { + log('Running onStart handler.'); + + return handleRequest({ + snapId, + store, + executionService, + controllerMessenger, + runSaga, + handler: HandlerType.OnStart, + request: { + method: '', + ...request, + }, + }); + }, + onNameLookup: async ( nameLookupOptions: NameLookupOptions, ): Promise => { diff --git a/packages/snaps-simulation/src/types.ts b/packages/snaps-simulation/src/types.ts index 37aa907730..73dec241bb 100644 --- a/packages/snaps-simulation/src/types.ts +++ b/packages/snaps-simulation/src/types.ts @@ -492,6 +492,13 @@ export type Snap = { */ onUpdate(request?: Pick): SnapRequest; + /** + * Get the response from the Snap's `onStart` handler. + * + * @returns The response. + */ + onStart(request?: Pick): SnapRequest; + /** * Get the response from the Snap's `onNameLookup` handler. * diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index e800862e07..5eee8de081 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.76, - "functions": 99, + "functions": 99.01, "lines": 98.69, "statements": 97.29 } diff --git a/packages/snaps-utils/src/handlers/exports.test.ts b/packages/snaps-utils/src/handlers/exports.test.ts index 8a0b081846..a89da358b8 100644 --- a/packages/snaps-utils/src/handlers/exports.test.ts +++ b/packages/snaps-utils/src/handlers/exports.test.ts @@ -21,6 +21,7 @@ describe('SNAP_EXPORT_NAMES', () => { 'onCronjob', 'onInstall', 'onUpdate', + 'onStart', 'onNameLookup', 'onKeyringRequest', 'onHomePage', diff --git a/packages/snaps-utils/src/handlers/exports.ts b/packages/snaps-utils/src/handlers/exports.ts index 656e79d3de..82aaf19de1 100644 --- a/packages/snaps-utils/src/handlers/exports.ts +++ b/packages/snaps-utils/src/handlers/exports.ts @@ -12,6 +12,7 @@ import type { OnRpcRequestHandler, OnSettingsPageHandler, OnSignatureHandler, + OnStartHandler, OnTransactionHandler, OnUpdateHandler, OnUserInputHandler, @@ -63,6 +64,13 @@ export const SNAP_EXPORTS = { return typeof snapExport === 'function'; }, }, + [HandlerType.OnStart]: { + type: HandlerType.OnStart, + required: false, + validator: (snapExport: unknown): snapExport is OnStartHandler => { + return typeof snapExport === 'function'; + }, + }, [HandlerType.OnKeyringRequest]: { type: HandlerType.OnKeyringRequest, required: true, diff --git a/packages/snaps-utils/src/handlers/types.ts b/packages/snaps-utils/src/handlers/types.ts index 371e7932a8..af3ad495f8 100644 --- a/packages/snaps-utils/src/handlers/types.ts +++ b/packages/snaps-utils/src/handlers/types.ts @@ -35,6 +35,7 @@ export enum HandlerType { OnCronjob = 'onCronjob', OnInstall = 'onInstall', OnUpdate = 'onUpdate', + OnStart = 'onStart', OnNameLookup = 'onNameLookup', OnKeyringRequest = 'onKeyringRequest', OnHomePage = 'onHomePage',