Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 181 additions & 2 deletions packages/snaps-controllers/src/snaps/SnapController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
Caveat,
SubjectPermissions,
ValidPermission,
CaveatConstraint,
} from '@metamask/permission-controller';
import { SubjectType } from '@metamask/permission-controller';
import { providerErrors, rpcErrors } from '@metamask/rpc-errors';
Expand Down Expand Up @@ -10336,7 +10337,11 @@ describe('SnapController', () => {

rootMessenger.registerActionHandler(
'PermissionController:getPermissions',
(origin) => {
(
origin,
): SubjectPermissions<
ValidPermission<TargetName, CaveatConstraint>
> => {
if (origin === MOCK_SNAP_ID) {
return {
[SnapEndowments.LifecycleHooks]:
Expand Down Expand Up @@ -10443,7 +10448,7 @@ describe('SnapController', () => {
await sleep(10);

expect(consoleErrorSpy).toHaveBeenCalledWith(
`Error when calling \`onStart\` lifecycle hook for Snap "npm:@metamask/example-snap": Test error in lifecycle hook.`,
`Error calling lifecycle hook "onStart" for Snap "npm:@metamask/example-snap": Test error in lifecycle hook.`,
);

snapController.destroy();
Expand Down Expand Up @@ -12369,4 +12374,178 @@ describe('SnapController', () => {
snapController.destroy();
});
});

describe('SnapController:setActive', () => {
it('calls the `onActive` lifecycle hook for all Snaps when called with `true`', async () => {
const rootMessenger = getControllerMessenger();
const messenger = getSnapControllerMessenger(rootMessenger);

rootMessenger.registerActionHandler(
'PermissionController:hasPermission',
() => true,
);

rootMessenger.registerActionHandler(
'PermissionController:getPermissions',
(
origin,
): SubjectPermissions<ValidPermission<string, CaveatConstraint>> => {
if (origin === MOCK_SNAP_ID) {
return {
[SnapEndowments.LifecycleHooks]: MOCK_LIFECYCLE_HOOKS_PERMISSION,
};
}

return {};
},
);

const manifest = getSnapManifest({
initialPermissions: {
[SnapEndowments.LifecycleHooks]: {},
},
});

const snapController = getSnapController(
getSnapControllerOptions({
messenger,
state: {
snaps: getPersistedSnapsState(getPersistedSnapObject({ manifest })),
},
}),
);

messenger.call('SnapController:setActive', true);
await sleep(10);

expect(messenger.call).toHaveBeenNthCalledWith(
2,
'PermissionController:hasPermission',
MOCK_SNAP_ID,
SnapEndowments.LifecycleHooks,
);

expect(messenger.call).toHaveBeenNthCalledWith(
3,
'PermissionController:hasPermission',
MOCK_SNAP_ID,
SnapEndowments.LifecycleHooks,
);

expect(messenger.call).toHaveBeenNthCalledWith(
4,
'PermissionController:getPermissions',
MOCK_SNAP_ID,
);

expect(messenger.call).toHaveBeenNthCalledWith(
5,
'ExecutionService:executeSnap',
expect.any(Object),
);

expect(messenger.call).toHaveBeenNthCalledWith(
6,
'ExecutionService:handleRpcRequest',
MOCK_SNAP_ID,
{
handler: HandlerType.OnActive,
origin: METAMASK_ORIGIN,
request: {
jsonrpc: '2.0',
id: expect.any(String),
method: HandlerType.OnActive,
},
},
);

snapController.destroy();
});

it('calls the `onInactive` lifecycle hook for all Snaps when called with `false`', async () => {
const rootMessenger = getControllerMessenger();
const messenger = getSnapControllerMessenger(rootMessenger);

rootMessenger.registerActionHandler(
'PermissionController:hasPermission',
() => true,
);

rootMessenger.registerActionHandler(
'PermissionController:getPermissions',
(
origin,
): SubjectPermissions<ValidPermission<string, CaveatConstraint>> => {
if (origin === MOCK_SNAP_ID) {
return {
[SnapEndowments.LifecycleHooks]: MOCK_LIFECYCLE_HOOKS_PERMISSION,
};
}

return {};
},
);

const manifest = getSnapManifest({
initialPermissions: {
[SnapEndowments.LifecycleHooks]: {},
},
});

const snapController = getSnapController(
getSnapControllerOptions({
messenger,
state: {
snaps: getPersistedSnapsState(getPersistedSnapObject({ manifest })),
},
}),
);

messenger.call('SnapController:setActive', false);
await sleep(10);

expect(messenger.call).toHaveBeenNthCalledWith(
2,
'PermissionController:hasPermission',
MOCK_SNAP_ID,
SnapEndowments.LifecycleHooks,
);

expect(messenger.call).toHaveBeenNthCalledWith(
3,
'PermissionController:hasPermission',
MOCK_SNAP_ID,
SnapEndowments.LifecycleHooks,
);

expect(messenger.call).toHaveBeenNthCalledWith(
4,
'PermissionController:getPermissions',
MOCK_SNAP_ID,
);

expect(messenger.call).toHaveBeenNthCalledWith(
5,
'ExecutionService:executeSnap',
expect.any(Object),
);

expect(messenger.call).toHaveBeenNthCalledWith(
6,
'ExecutionService:handleRpcRequest',
MOCK_SNAP_ID,
{
handler: HandlerType.OnInactive,
origin: METAMASK_ORIGIN,
request: {
jsonrpc: '2.0',
id: expect.any(String),
method: HandlerType.OnInactive,
},
},
);

snapController.destroy();
});
});
});
82 changes: 58 additions & 24 deletions packages/snaps-controllers/src/snaps/SnapController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,11 @@ export type IsMinimumPlatformVersion = {
handler: SnapController['isMinimumPlatformVersion'];
};

export type SetActive = {
type: `${typeof controllerName}:setActive`;
handler: SnapController['setActive'];
};

export type SnapControllerGetStateAction = ControllerGetStateAction<
typeof controllerName,
SnapControllerState
Expand Down Expand Up @@ -507,7 +512,8 @@ export type SnapControllerActions =
| GetSnapFile
| SnapControllerGetStateAction
| StopAllSnaps
| IsMinimumPlatformVersion;
| IsMinimumPlatformVersion
| SetActive;

// Controller Messenger Events

Expand Down Expand Up @@ -1293,37 +1299,21 @@ export class SnapController extends BaseController<
`${controllerName}:isMinimumPlatformVersion`,
(...args) => this.isMinimumPlatformVersion(...args),
);

this.messagingSystem.registerActionHandler(
`${controllerName}:setActive`,
(...args) => this.setActive(...args),
);
}

/**
* Initialise the SnapController.
*
* Currently this method calls the `onStart` lifecycle hook for all
* installed Snaps.
* runnable 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,
)}`,
);
},
);
}
this.#callLifecycleHooks(METAMASK_ORIGIN, HandlerType.OnStart);
}

#handlePreinstalledSnaps(preinstalledSnaps: PreinstalledSnap[]) {
Expand Down Expand Up @@ -3713,6 +3703,20 @@ export class SnapController extends BaseController<
}
}

/**
* Set the active state of the client. This will trigger the `onActive` or
* `onInactive` lifecycle hooks for all Snaps.
*
* @param active - A boolean indicating whether the client is active or not.
*/
setActive(active: boolean) {
Copy link
Member Author

@Mrtenz Mrtenz Jul 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be the easiest solution given that at least the extension doesn't seem to emit any events when the active status changes. I'm open to other suggestions though.

https://github.com/MetaMask/metamask-extension/blob/6e6be317110039c50f85d2768d24e9c81f3f395b/app/scripts/metamask-controller.js#L8585-L8592

if (active) {
this.#callLifecycleHooks(METAMASK_ORIGIN, HandlerType.OnActive);
} else {
this.#callLifecycleHooks(METAMASK_ORIGIN, HandlerType.OnInactive);
}
}

/**
* Determine the execution timeout for a given handler permission.
*
Expand Down Expand Up @@ -4451,6 +4455,36 @@ export class SnapController extends BaseController<
return true;
}

/**
* Call a lifecycle hook for all runnable Snaps.
*
* @param origin - The origin of the request.
* @param handler - The lifecycle hook to call. This should be one of the
* supported lifecycle hooks.
*/
#callLifecycleHooks(origin: string, handler: HandlerType) {
const snaps = this.getRunnableSnaps();
for (const { id } of snaps) {
const hasLifecycleHooksEndowment = this.messagingSystem.call(
'PermissionController:hasPermission',
id,
SnapEndowments.LifecycleHooks,
);

if (!hasLifecycleHooksEndowment) {
continue;
}

this.#callLifecycleHook(origin, id, handler).catch((error) => {
logError(
`Error calling lifecycle hook "${handler}" for Snap "${id}": ${getErrorMessage(
error,
)}`,
);
});
}
}

/**
* Call a lifecycle hook on a snap, if the snap has the
* `endowment:lifecycle-hooks` permission. If the snap does not have the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1899,6 +1899,8 @@ describe('BaseSnapExecutor', () => {
HandlerType.OnInstall,
HandlerType.OnUpdate,
HandlerType.OnStart,
HandlerType.OnActive,
HandlerType.OnInactive,
];

for (const handler of LIFECYCLE_HOOKS) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export function getHandlerArguments(
case HandlerType.OnInstall:
case HandlerType.OnUpdate:
case HandlerType.OnStart:
case HandlerType.OnActive:
case HandlerType.OnInactive:
return { origin };

case HandlerType.OnHomePage:
Expand Down
2 changes: 2 additions & 0 deletions packages/snaps-rpc-methods/src/endowments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export const handlerEndowments: Record<HandlerType, string | null> = {
[HandlerType.OnInstall]: lifecycleHooksEndowmentBuilder.targetName,
[HandlerType.OnUpdate]: lifecycleHooksEndowmentBuilder.targetName,
[HandlerType.OnStart]: lifecycleHooksEndowmentBuilder.targetName,
[HandlerType.OnActive]: lifecycleHooksEndowmentBuilder.targetName,
[HandlerType.OnInactive]: lifecycleHooksEndowmentBuilder.targetName,
[HandlerType.OnKeyringRequest]: keyringEndowmentBuilder.targetName,
[HandlerType.OnHomePage]: homePageEndowmentBuilder.targetName,
[HandlerType.OnSettingsPage]: settingsPageEndowmentBuilder.targetName,
Expand Down
2 changes: 2 additions & 0 deletions packages/snaps-utils/src/handlers/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ describe('SNAP_EXPORT_NAMES', () => {
'onInstall',
'onUpdate',
'onStart',
'onActive',
'onInactive',
'onNameLookup',
'onKeyringRequest',
'onHomePage',
Expand Down
14 changes: 14 additions & 0 deletions packages/snaps-utils/src/handlers/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ export const SNAP_EXPORTS = {
return typeof snapExport === 'function';
},
},
[HandlerType.OnActive]: {
type: HandlerType.OnActive,
required: false,
validator: (snapExport: unknown): snapExport is OnStartHandler => {
return typeof snapExport === 'function';
},
},
[HandlerType.OnInactive]: {
type: HandlerType.OnInactive,
required: false,
validator: (snapExport: unknown): snapExport is OnStartHandler => {
return typeof snapExport === 'function';
},
},
[HandlerType.OnKeyringRequest]: {
type: HandlerType.OnKeyringRequest,
required: true,
Expand Down
2 changes: 2 additions & 0 deletions packages/snaps-utils/src/handlers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export enum HandlerType {
OnInstall = 'onInstall',
OnUpdate = 'onUpdate',
OnStart = 'onStart',
OnActive = 'onActive',
OnInactive = 'onInactive',
OnNameLookup = 'onNameLookup',
OnKeyringRequest = 'onKeyringRequest',
OnHomePage = 'onHomePage',
Expand Down
Loading