Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
68 changes: 59 additions & 9 deletions packages/gator-permissions-snap/src/core/dialogInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,73 @@ export class DialogInterface {
return this.#interfaceId;
}

/**
* Tries to close the dialog interface.
* @param interfaceId - The ID of the interface to attempt to close.
* @returns boolean indicating whether the attempt was successful.
*/
async #tryToClose(interfaceId: string): Promise<boolean> {
try {
await this.#snap.request({
method: 'snap_resolveInterface',
params: { id: interfaceId, value: {} },
});
return true;
} catch {
return false;
}
}

/**
* Checks whether the specified interface exists.
* @param interfaceId - The ID of the interface to check.
* @returns boolean indicating whether the interface exists.
*/
async #doesInterfaceExist(interfaceId: string): Promise<boolean> {
try {
await this.#snap.request({
method: 'snap_getInterfaceContext',
params: { id: interfaceId },
});
return true;
} catch {
return false;
}
}

/**
* Programmatically close the dialog.
* Safe to call multiple times.
* Best effort: attempts up to three close requests; always clears local state and resolves.
* Callers in confirmation.tsx do not catch errors, so this method does not throw.
*/
async close(): Promise<void> {
if (this.#interfaceId) {
try {
await this.#snap.request({
method: 'snap_resolveInterface',
params: { id: this.#interfaceId, value: {} },
});
} catch {
// Silently ignore - dialog may already be closed
}
const MAX_ATTEMPTS = 3;

const cleanup = (): void => {
this.#interfaceId = undefined;
this.#isDialogShown = false;
};

if (!this.#interfaceId) {
return;
}

const id = this.#interfaceId;

for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
if (await this.#tryToClose(id)) {
cleanup();
return;
}

if (!(await this.#doesInterfaceExist(id))) {
cleanup();
return;
}
}

cleanup();
}

/**
Expand Down
1 change: 0 additions & 1 deletion packages/gator-permissions-snap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ const snapsMetricsService = new SnapsMetricsService(snap);

const profileSyncManager = createProfileSyncManager({
isFeatureEnabled: isStorePermissionsFeatureEnabled,
auth,
userStorage: new UserStorage(
{
auth,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ describe('DialogInterface', () => {
describe('close', () => {
const mockInterfaceId = 'test-interface-123';

it('should resolve the interface', async () => {
it('should resolve the interface and clear state on first attempt', async () => {
mockSnapsProvider.request.mockImplementation(async (params: any) => {
if (params.method === 'snap_createInterface') {
return mockInterfaceId;
Expand All @@ -234,13 +234,14 @@ describe('DialogInterface', () => {
value: {},
},
});
expect(dialogInterface.interfaceId).toBeUndefined();
});

it('should be safe to call when no interface exists', async () => {
await expect(dialogInterface.close()).resolves.not.toThrow();
await expect(dialogInterface.close()).resolves.toBeUndefined();
});

it('should silently ignore errors when closing', async () => {
it('should not throw when close fails and interface is already gone', async () => {
mockSnapsProvider.request.mockImplementation(async (params: any) => {
if (params.method === 'snap_createInterface') {
return mockInterfaceId;
Expand All @@ -251,11 +252,90 @@ describe('DialogInterface', () => {
if (params.method === 'snap_resolveInterface') {
throw new Error('Already resolved');
}
if (params.method === 'snap_getInterfaceContext') {
throw new Error('Interface not found');
}
return null;
});

await dialogInterface.show(<Text>Test</Text>);
await expect(dialogInterface.close()).resolves.toBeUndefined();
expect(dialogInterface.interfaceId).toBeUndefined();
});

it('should retry and clear state when first close fails but second succeeds', async () => {
let resolveCallCount = 0;
mockSnapsProvider.request.mockImplementation(async (params: any) => {
if (params.method === 'snap_createInterface') {
return mockInterfaceId;
}
if (params.method === 'snap_dialog') {
return new Promise(() => {});
}
if (params.method === 'snap_resolveInterface') {
resolveCallCount += 1;
if (resolveCallCount === 1) {
throw new Error('Temporary failure');
}
return null;
}
if (params.method === 'snap_getInterfaceContext') {
return {};
}
return null;
});

await dialogInterface.show(<Text>Test</Text>);
await dialogInterface.close();

expect(resolveCallCount).toBe(2);
expect(dialogInterface.interfaceId).toBeUndefined();
});

it('should not throw when all close attempts fail', async () => {
mockSnapsProvider.request.mockImplementation(async (params: any) => {
if (params.method === 'snap_createInterface') {
return mockInterfaceId;
}
if (params.method === 'snap_dialog') {
return new Promise(() => {});
}
if (params.method === 'snap_resolveInterface') {
throw new Error('Close failed');
}
if (params.method === 'snap_getInterfaceContext') {
return {};
}
return null;
});

await dialogInterface.show(<Text>Test</Text>);
await expect(dialogInterface.close()).resolves.toBeUndefined();
});

it('should clear state after exhausting retries when all close attempts fail', async () => {
mockSnapsProvider.request.mockImplementation(async (params: any) => {
if (params.method === 'snap_createInterface') {
return mockInterfaceId;
}
if (params.method === 'snap_dialog') {
return new Promise(() => {});
}
if (params.method === 'snap_resolveInterface') {
throw new Error('Close failed');
}
if (params.method === 'snap_getInterfaceContext') {
return {};
}
return null;
});

await dialogInterface.show(<Text>Test</Text>);
await expect(dialogInterface.close()).resolves.not.toThrow();
expect(dialogInterface.interfaceId).toBe(mockInterfaceId);

await dialogInterface.close();

expect(dialogInterface.interfaceId).toBeUndefined();
});
});

Expand Down
Loading