Skip to content

Commit 102e818

Browse files
feat: Add onStart lifecycle hook (#3455)
This adds a new lifecycle hook "onStart", to be called when the client starts. The Snap can use this to perform any initialisation needed, such as setting up a WebSocket connection or background event. Closes #3453. --------- Co-authored-by: Frederik Bolding <frederik.bolding@gmail.com>
1 parent 443340f commit 102e818

File tree

21 files changed

+330
-18
lines changed

21 files changed

+330
-18
lines changed

packages/examples/packages/lifecycle-hooks/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# `@metamask/lifecylce-hooks-example-snap`
22

3-
This snap demonstrates how to use the `onInstall` and `onUpdate` lifecycle
4-
hooks.
3+
This Snap demonstrates how to use the `onStart`, `onInstall`, and `onUpdate`
4+
lifecycle hooks.
55

66
## Snap manifest
77

@@ -22,9 +22,9 @@ Along with other permissions, the manifest of this snap includes the
2222

2323
## Snap usage
2424

25-
This snap exposes the `onInstall` and `onUpdate` lifecycle hooks. These hooks
26-
are called when the snap is installed or updated, respectively, and cannot be
27-
called manually.
25+
This Snap exposes the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks.
26+
These hooks are called when the client is started, when the Snap is installed,
27+
or the Snap is updated, respectively, and cannot be called manually.
2828

2929
For more information, you can refer to
3030
[the end-to-end tests](./src/index.test.ts).

packages/examples/packages/lifecycle-hooks/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@metamask/lifecycle-hooks-example-snap",
33
"version": "2.1.3",
4-
"description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks",
4+
"description": "MetaMask example snap demonstrating the use of the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks",
55
"keywords": [
66
"MetaMask",
77
"Snaps",

packages/examples/packages/lifecycle-hooks/snap.manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"version": "2.1.3",
3-
"description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks.",
3+
"description": "MetaMask example snap demonstrating the use of the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks.",
44
"proposedName": "Lifecycle Hooks Example Snap",
55
"repository": {
66
"type": "git",
77
"url": "https://github.com/MetaMask/snaps.git"
88
},
99
"source": {
10-
"shasum": "q+mPr3Gq/2PZDIrqKu+wR7sh1hwKk+F5OoEo1ye+30Q=",
10+
"shasum": "c6HavArVNGdBQlFYdVeKXpahvU0DHpB6D6UupoexRR0=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

packages/examples/packages/lifecycle-hooks/src/index.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@ import { describe, expect, it } from '@jest/globals';
22
import { assertIsAlertDialog, installSnap } from '@metamask/snaps-jest';
33
import { Box, Text } from '@metamask/snaps-sdk/jsx';
44

5+
describe('onStart', () => {
6+
it('shows dialog when the client is started', async () => {
7+
const { onStart } = await installSnap();
8+
9+
const response = onStart();
10+
11+
const screen = await response.getInterface();
12+
assertIsAlertDialog(screen);
13+
14+
expect(screen).toRender(
15+
<Box>
16+
<Text>
17+
The client was started successfully, and the "onStart" handler was
18+
called.
19+
</Text>
20+
</Box>,
21+
);
22+
23+
await screen.ok();
24+
25+
expect(await response).toRespondWith(null);
26+
});
27+
});
28+
529
describe('onInstall', () => {
630
it('shows dialog when the snap is installed', async () => {
731
const { onInstall } = await installSnap();

packages/examples/packages/lifecycle-hooks/src/index.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
1-
import type { OnInstallHandler, OnUpdateHandler } from '@metamask/snaps-sdk';
1+
import type {
2+
OnInstallHandler,
3+
OnStartHandler,
4+
OnUpdateHandler,
5+
} from '@metamask/snaps-sdk';
26
import { Box, Text } from '@metamask/snaps-sdk/jsx';
37

8+
/**
9+
* Handle starting of the client. This handler is called when the client is
10+
* started, and can be used to perform any initialization that is required.
11+
*
12+
* This handler is optional. If it is not provided, the Snap will be started
13+
* as usual.
14+
*
15+
* @see https://docs.metamask.io/snaps/reference/entry-points/#onstart
16+
* @returns The JSON-RPC response.
17+
*/
18+
export const onStart: OnStartHandler = async () => {
19+
return await snap.request({
20+
method: 'snap_dialog',
21+
params: {
22+
type: 'alert',
23+
content: (
24+
<Box>
25+
<Text>
26+
The client was started successfully, and the "onStart" handler was
27+
called.
28+
</Text>
29+
</Box>
30+
),
31+
},
32+
});
33+
};
34+
435
/**
536
* Handle installation of the snap. This handler is called when the snap is
637
* installed, and can be used to perform any initialization that is required.'
738
*
839
* This handler is optional. If it is not provided, the snap will be installed
940
* as usual.
1041
*
11-
* @see https://docs.metamask.io/snaps/reference/exports/#oninstall
42+
* @see https://docs.metamask.io/snaps/reference/entry-points/#oninstall
1243
* @returns The JSON-RPC response.
1344
*/
1445
export const onInstall: OnInstallHandler = async () => {
@@ -35,7 +66,7 @@ export const onInstall: OnInstallHandler = async () => {
3566
* This handler is optional. If it is not provided, the snap will be updated
3667
* as usual.
3768
*
38-
* @see https://docs.metamask.io/snaps/reference/exports/#onupdate
69+
* @see https://docs.metamask.io/snaps/reference/entry-points/#onupdate
3970
* @returns The JSON-RPC response.
4071
*/
4172
export const onUpdate: OnUpdateHandler = async () => {
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"branches": 95.21,
3-
"functions": 98.71,
2+
"branches": 95.22,
3+
"functions": 98.72,
44
"lines": 98.87,
5-
"statements": 98.7
5+
"statements": 98.71
66
}

packages/snaps-controllers/src/snaps/SnapController.test.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9889,6 +9889,127 @@ describe('SnapController', () => {
98899889
});
98909890

98919891
describe('SnapController actions', () => {
9892+
describe('SnapController:init', () => {
9893+
it('calls `onStart` for all Snaps with the `endowment:lifecycle-hooks` permission', async () => {
9894+
const rootMessenger = getControllerMessenger();
9895+
const messenger = getSnapControllerMessenger(rootMessenger);
9896+
9897+
rootMessenger.registerActionHandler(
9898+
'PermissionController:getPermissions',
9899+
(origin) => {
9900+
if (origin === MOCK_SNAP_ID) {
9901+
return {
9902+
[SnapEndowments.LifecycleHooks]:
9903+
MOCK_LIFECYCLE_HOOKS_PERMISSION,
9904+
};
9905+
}
9906+
9907+
return {};
9908+
},
9909+
);
9910+
9911+
rootMessenger.registerActionHandler(
9912+
'PermissionController:hasPermission',
9913+
(origin) => {
9914+
return origin === MOCK_SNAP_ID;
9915+
},
9916+
);
9917+
9918+
const snapController = getSnapController(
9919+
getSnapControllerOptions({
9920+
messenger,
9921+
state: {
9922+
snaps: getPersistedSnapsState(
9923+
getPersistedSnapObject({
9924+
id: MOCK_SNAP_ID,
9925+
}),
9926+
getPersistedSnapObject({
9927+
id: MOCK_LOCAL_SNAP_ID,
9928+
}),
9929+
),
9930+
},
9931+
}),
9932+
);
9933+
9934+
const call = jest.spyOn(messenger, 'call');
9935+
messenger.call('SnapController:init');
9936+
await sleep(10);
9937+
9938+
expect(call).toHaveBeenNthCalledWith(
9939+
2,
9940+
'PermissionController:hasPermission',
9941+
MOCK_SNAP_ID,
9942+
'endowment:lifecycle-hooks',
9943+
);
9944+
9945+
expect(call).toHaveBeenNthCalledWith(
9946+
6,
9947+
'ExecutionService:executeSnap',
9948+
expect.any(Object),
9949+
);
9950+
9951+
expect(messenger.call).toHaveBeenNthCalledWith(
9952+
7,
9953+
'ExecutionService:handleRpcRequest',
9954+
MOCK_SNAP_ID,
9955+
{
9956+
handler: HandlerType.OnStart,
9957+
origin: METAMASK_ORIGIN,
9958+
request: {
9959+
jsonrpc: '2.0',
9960+
id: expect.any(String),
9961+
method: HandlerType.OnStart,
9962+
},
9963+
},
9964+
);
9965+
9966+
snapController.destroy();
9967+
});
9968+
9969+
it('logs an error if the lifecycle hook throws', async () => {
9970+
const consoleErrorSpy = jest
9971+
.spyOn(console, 'error')
9972+
.mockImplementation();
9973+
9974+
const rootMessenger = getControllerMessenger();
9975+
const messenger = getSnapControllerMessenger(rootMessenger);
9976+
9977+
rootMessenger.registerActionHandler(
9978+
'PermissionController:getPermissions',
9979+
() => {
9980+
return {
9981+
[SnapEndowments.LifecycleHooks]: MOCK_LIFECYCLE_HOOKS_PERMISSION,
9982+
};
9983+
},
9984+
);
9985+
9986+
rootMessenger.registerActionHandler(
9987+
'ExecutionService:handleRpcRequest',
9988+
() => {
9989+
throw new Error('Test error in lifecycle hook.');
9990+
},
9991+
);
9992+
9993+
const snapController = getSnapController(
9994+
getSnapControllerOptions({
9995+
messenger,
9996+
state: {
9997+
snaps: getPersistedSnapsState(),
9998+
},
9999+
}),
10000+
);
10001+
10002+
messenger.call('SnapController:init');
10003+
await sleep(10);
10004+
10005+
expect(consoleErrorSpy).toHaveBeenCalledWith(
10006+
`Error when calling \`onStart\` lifecycle hook for Snap "npm:@metamask/example-snap": Test error in lifecycle hook.`,
10007+
);
10008+
10009+
snapController.destroy();
10010+
});
10011+
});
10012+
989210013
describe('SnapController:get', () => {
989310014
it('gets a snap', () => {
989410015
const messenger = getSnapControllerMessenger();

packages/snaps-controllers/src/snaps/SnapController.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,15 @@ type PendingApproval = {
338338

339339
// Controller Messenger Actions
340340

341+
/**
342+
* Initialise the SnapController. This should be called after all controllers
343+
* are created.
344+
*/
345+
export type SnapControllerInitAction = {
346+
type: `${typeof controllerName}:init`;
347+
handler: SnapController['init'];
348+
};
349+
341350
/**
342351
* Gets the specified Snap from state.
343352
*/
@@ -470,6 +479,7 @@ export type SnapControllerGetStateAction = ControllerGetStateAction<
470479
>;
471480

472481
export type SnapControllerActions =
482+
| SnapControllerInitAction
473483
| ClearSnapState
474484
| GetSnap
475485
| GetSnapState
@@ -1160,6 +1170,11 @@ export class SnapController extends BaseController<
11601170
* actions.
11611171
*/
11621172
#registerMessageHandlers(): void {
1173+
this.messagingSystem.registerActionHandler(
1174+
`${controllerName}:init`,
1175+
(...args) => this.init(...args),
1176+
);
1177+
11631178
this.messagingSystem.registerActionHandler(
11641179
`${controllerName}:clearSnapState`,
11651180
(...args) => this.clearSnapState(...args),
@@ -1266,6 +1281,37 @@ export class SnapController extends BaseController<
12661281
);
12671282
}
12681283

1284+
/**
1285+
* Initialise the SnapController.
1286+
*
1287+
* Currently this method calls the `onStart` lifecycle hook for all
1288+
* installed Snaps.
1289+
*/
1290+
init() {
1291+
const snaps = this.getRunnableSnaps();
1292+
for (const { id } of snaps) {
1293+
const hasLifecycleHooksEndowment = this.messagingSystem.call(
1294+
'PermissionController:hasPermission',
1295+
id,
1296+
SnapEndowments.LifecycleHooks,
1297+
);
1298+
1299+
if (!hasLifecycleHooksEndowment) {
1300+
continue;
1301+
}
1302+
1303+
this.#callLifecycleHook(METAMASK_ORIGIN, id, HandlerType.OnStart).catch(
1304+
(error) => {
1305+
logError(
1306+
`Error when calling \`onStart\` lifecycle hook for Snap "${id}": ${getErrorMessage(
1307+
error,
1308+
)}`,
1309+
);
1310+
},
1311+
);
1312+
}
1313+
}
1314+
12691315
#handlePreinstalledSnaps(preinstalledSnaps: PreinstalledSnap[]) {
12701316
for (const {
12711317
snapId,
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"branches": 90,
2+
"branches": 90.04,
33
"functions": 94.69,
4-
"lines": 90.41,
4+
"lines": 90.42,
55
"statements": 89.62
66
}

packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,11 @@ describe('BaseSnapExecutor', () => {
18961896
});
18971897

18981898
describe('lifecycle hooks', () => {
1899-
const LIFECYCLE_HOOKS = [HandlerType.OnInstall, HandlerType.OnUpdate];
1899+
const LIFECYCLE_HOOKS = [
1900+
HandlerType.OnInstall,
1901+
HandlerType.OnUpdate,
1902+
HandlerType.OnStart,
1903+
];
19001904

19011905
for (const handler of LIFECYCLE_HOOKS) {
19021906
it(`supports \`${handler}\` export`, async () => {

0 commit comments

Comments
 (0)