Skip to content

Commit

Permalink
implement interface persistence
Browse files Browse the repository at this point in the history
  • Loading branch information
hmalik88 committed Oct 23, 2024
1 parent dd08ed6 commit 43ce993
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { getPersistentState } from '@metamask/base-controller';
import type { SnapId } from '@metamask/snaps-sdk';
import { form, image, input, panel, text } from '@metamask/snaps-sdk';
import {
form,
image,
input,
panel,
text,
ContentType,
} from '@metamask/snaps-sdk';
import {
Box,
Field,
Expand Down Expand Up @@ -29,6 +37,40 @@ jest.mock('@metamask/snaps-utils', () => ({
}));

describe('SnapInterfaceController', () => {
describe('constructor', () => {
it('persists notification interfaces', () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

const controller = new SnapInterfaceController({
messenger: controllerMessenger,
state: {
interfaces: {
// @ts-expect-error missing properties
'1': {
contentType: ContentType.Notification,
},
// @ts-expect-error missing properties
'2': {
contentType: ContentType.Dialog,
},
},
},
});

expect(
getPersistentState(controller.state, controller.metadata),
).toStrictEqual({
interfaces: {
'1': {
contentType: ContentType.Notification,
},
},
});
});
});

describe('createInterface', () => {
it('can create a new interface', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
Expand Down Expand Up @@ -160,6 +202,41 @@ describe('SnapInterfaceController', () => {
expect(context).toStrictEqual({ foo: 'bar' });
});

it('supports providing an interface content type', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

/* eslint-disable-next-line no-new */
new SnapInterfaceController({
messenger: controllerMessenger,
});

const element = (
<Box>
<Text>
<Link href="https://foo.bar">foo</Link>
</Text>
</Box>
);

const id = await rootMessenger.call(
'SnapInterfaceController:createInterface',
MOCK_SNAP_ID,
element,
{ foo: 'bar' },
ContentType.Notification,
);

const { contentType } = rootMessenger.call(
'SnapInterfaceController:getInterface',
MOCK_SNAP_ID,
id,
);

expect(contentType).toStrictEqual(ContentType.Notification);
});

it('throws if interface context is too large', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
Expand Down Expand Up @@ -1068,6 +1145,51 @@ describe('SnapInterfaceController', () => {
});
});

describe('updateInterfaceContentType', () => {
it("can update an interface's content type", async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

/* eslint-disable-next-line no-new */
new SnapInterfaceController({
messenger: controllerMessenger,
});

const content = form({ name: 'foo', children: [input({ name: 'bar' })] });

let contentType;

const id = await rootMessenger.call(
'SnapInterfaceController:createInterface',
MOCK_SNAP_ID,
content,
);

contentType = rootMessenger.call(
'SnapInterfaceController:getInterface',
MOCK_SNAP_ID,
id,
).contentType;

expect(contentType).toBeNull();

rootMessenger.call(
'SnapInterfaceController:updateInterfaceContentType',
id,
ContentType.Dialog,
);

contentType = rootMessenger.call(
'SnapInterfaceController:getInterface',
MOCK_SNAP_ID,
id,
).contentType;

expect(contentType).toStrictEqual(ContentType.Dialog);
});
});

describe('resolveInterface', () => {
it('resolves the interface with the given value', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
ComponentOrElement,
InterfaceContext,
} from '@metamask/snaps-sdk';
import { ContentType } from '@metamask/snaps-sdk';
import type { JSXElement } from '@metamask/snaps-sdk/jsx';
import { getJsonSizeUnsafe, validateJsxLinks } from '@metamask/snaps-utils';
import type { Json } from '@metamask/utils';
Expand Down Expand Up @@ -61,6 +62,11 @@ export type UpdateInterfaceState = {
handler: SnapInterfaceController['updateInterfaceState'];
};

export type UpdateInterfaceContentType = {
type: `${typeof controllerName}:updateInterfaceContentType`;
handler: SnapInterfaceController['updateInterfaceContentType'];
};

export type ResolveInterface = {
type: `${typeof controllerName}:resolveInterface`;
handler: SnapInterfaceController['resolveInterface'];
Expand All @@ -84,6 +90,7 @@ export type SnapInterfaceControllerActions =
| UpdateInterface
| DeleteInterface
| UpdateInterfaceState
| UpdateInterfaceContentType
| ResolveInterface
| SnapInterfaceControllerGetStateAction;

Expand All @@ -109,6 +116,7 @@ export type StoredInterface = {
content: JSXElement;
state: InterfaceState;
context: InterfaceContext | null;
contentType: ContentType | null;
};

export type SnapInterfaceControllerState = {
Expand All @@ -132,7 +140,22 @@ export class SnapInterfaceController extends BaseController<
super({
messenger,
metadata: {
interfaces: { persist: false, anonymous: false },
interfaces: {
persist: (interfaces: Record<string, StoredInterface>) => {
return Object.entries(interfaces).reduce<
Record<string, StoredInterface>
>((persistedInterfaces, [id, snapInterface]) => {
switch (snapInterface.contentType) {
case ContentType.Notification:
persistedInterfaces[id] = snapInterface;
return persistedInterfaces;
default:
return persistedInterfaces;
}
}, {});
},
anonymous: false,
},
},
name: controllerName,
state: { interfaces: {}, ...state },
Expand Down Expand Up @@ -161,6 +184,11 @@ export class SnapInterfaceController extends BaseController<
this.updateInterface.bind(this),
);

this.messagingSystem.registerActionHandler(
`${controllerName}:updateInterfaceContentType`,
this.updateInterfaceContentType.bind(this),
);

this.messagingSystem.registerActionHandler(
`${controllerName}:deleteInterface`,
this.deleteInterface.bind(this),
Expand All @@ -183,12 +211,14 @@ export class SnapInterfaceController extends BaseController<
* @param snapId - The snap id that created the interface.
* @param content - The interface content.
* @param context - An optional interface context object.
* @param contentType - The type of content.
* @returns The newly interface id.
*/
async createInterface(
snapId: SnapId,
content: ComponentOrElement,
context?: InterfaceContext,
contentType?: ContentType,
) {
const element = getJsxInterface(content);
await this.#validateContent(element);
Expand All @@ -205,6 +235,7 @@ export class SnapInterfaceController extends BaseController<
content: castDraft(element),
state: componentState,
context: context ?? null,
contentType: contentType ?? null,
};
});

Expand Down Expand Up @@ -255,6 +286,20 @@ export class SnapInterfaceController extends BaseController<
});
}

/**
* Update the type of content in an interface.
*
* @param id - The interface id.
* @param contentType - The type of content.
*/
updateInterfaceContentType(id: string, contentType: ContentType) {
this.#validateContentType(contentType);
assert(this.state.interfaces[id], 'Interface does not exist.');
this.update((draftState) => {
draftState.interfaces[id].contentType = contentType;
});
}

/**
* Delete an interface from state.
*
Expand Down Expand Up @@ -393,4 +438,17 @@ export class SnapInterfaceController extends BaseController<
(id: string) => this.messagingSystem.call('SnapController:get', id),
);
}

/**
* Utility function to validate the type of interface content.
* Must be a value of the enum ContentType.
* Throws if the passed string is invalid.
*
* @param contentType - The content type.
*/
#validateContentType(contentType: string) {
if (!(contentType in ContentType)) {
throw new Error('Invalid content type.');
}
}
}
22 changes: 20 additions & 2 deletions packages/snaps-controllers/src/snaps/SnapController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ import type {
SnapId,
ComponentOrElement,
} from '@metamask/snaps-sdk';
import { AuxiliaryFileEncoding, getErrorMessage } from '@metamask/snaps-sdk';
import {
AuxiliaryFileEncoding,
getErrorMessage,
ContentType,
} from '@metamask/snaps-sdk';
import type {
FetchedSnapFiles,
InitialConnections,
Expand Down Expand Up @@ -110,7 +114,7 @@ import type { Patch } from 'immer';
import { nanoid } from 'nanoid';

import { forceStrict, validateMachine } from '../fsm';
import type { CreateInterface, GetInterface } from '../interface';
import { type CreateInterface, type GetInterface } from '../interface';
import { log } from '../logging';
import type {
ExecuteSnapAction,
Expand Down Expand Up @@ -3311,16 +3315,20 @@ export class SnapController extends BaseController<
*
* @param snapId - The snap ID.
* @param content - The initial interface content.
* @param contentType - The type of content.
* @returns An identifier that can be used to identify the interface.
*/
async #createInterface(
snapId: SnapId,
content: ComponentOrElement,
contentType: ContentType,
): Promise<string> {
return this.messagingSystem.call(
'SnapInterfaceController:createInterface',
snapId,
content,
undefined,
contentType,
);
}

Expand Down Expand Up @@ -3358,10 +3366,20 @@ export class SnapController extends BaseController<
// If a handler returns static content, we turn it into a dynamic UI
if (castResult && hasProperty(castResult, 'content')) {
const { content, ...rest } = castResult;
const getContentType = (handler: HandlerType) => {
if (
handler === HandlerType.OnSignature ||
handler === HandlerType.OnTransaction
) {
return ContentType.Insight;
}
return ContentType.HomePage;
};

const id = await this.#createInterface(
snapId,
content as ComponentOrElement,
getContentType(handlerType),
);

return { ...rest, id };
Expand Down
1 change: 1 addition & 0 deletions packages/snaps-controllers/src/test-utils/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = (
'PhishingController:maybeUpdateState',
'ApprovalController:hasRequest',
'ApprovalController:acceptRequest',
'SnapController:get',
],
allowedEvents: [],
});
Expand Down
Loading

0 comments on commit 43ce993

Please sign in to comment.