Skip to content

Commit 36c61e7

Browse files
committed
feat: add QrKeyringDeferredPromiseBridge
1 parent a9b0f29 commit 36c61e7

File tree

4 files changed

+187
-0
lines changed

4 files changed

+187
-0
lines changed

packages/keyring-eth-qr/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@metamask/eth-sig-util": "^8.2.0",
5656
"@metamask/keyring-utils": "workspace:^",
5757
"@metamask/utils": "^11.1.0",
58+
"async-mutex": "^0.5.0",
5859
"hdkey": "^2.1.0",
5960
"uuid": "^9.0.1"
6061
},
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { type QrScanRequest, QrScanRequestType } from './qr-keyring';
2+
import { QrKeyringDeferredPromiseBridge } from './qr-keyring-deferred-promise-bridge';
3+
4+
describe('QrKeyringDeferredPromiseBridge', () => {
5+
describe('requestScan', () => {
6+
it('calls `onScanRequested` if any', async () => {
7+
const request: QrScanRequest = {
8+
type: QrScanRequestType.PAIR,
9+
};
10+
const onScanRequested = jest.fn();
11+
const bridge = new QrKeyringDeferredPromiseBridge({
12+
onScanRequested,
13+
});
14+
onScanRequested.mockImplementation(() => {
15+
bridge.resolvePendingScan({
16+
type: 'test',
17+
cbor: 'testData',
18+
});
19+
});
20+
21+
await bridge.requestScan(request);
22+
23+
expect(onScanRequested).toHaveBeenCalledWith(request);
24+
});
25+
});
26+
27+
describe('resolvePendingScan', () => {
28+
it('resolves the pending scan with the given result', async () => {
29+
const request: QrScanRequest = {
30+
type: QrScanRequestType.PAIR,
31+
};
32+
const result = {
33+
type: 'test',
34+
cbor: 'testData',
35+
};
36+
const onScanRequested = jest.fn();
37+
const onScanResolved = jest.fn();
38+
const bridge = new QrKeyringDeferredPromiseBridge({
39+
onScanRequested,
40+
onScanResolved,
41+
});
42+
onScanRequested.mockImplementation(() => {
43+
bridge.resolvePendingScan(result);
44+
});
45+
46+
const pendingScan = await bridge.requestScan(request);
47+
48+
expect(onScanResolved).toHaveBeenCalledWith(result);
49+
expect(pendingScan).toStrictEqual(result);
50+
});
51+
52+
it('throws an error if no pending scan exists', () => {
53+
const bridge = new QrKeyringDeferredPromiseBridge();
54+
55+
expect(() => {
56+
bridge.resolvePendingScan({ type: 'test', cbor: 'testData' });
57+
}).toThrow('No pending scan to resolve.');
58+
});
59+
});
60+
61+
describe('rejectPendingScan', () => {
62+
it('calls `onScanRejected` if any', async () => {
63+
const error = new Error('Test error');
64+
const onScanRejected = jest.fn();
65+
const onScanRequested = jest.fn();
66+
const bridge = new QrKeyringDeferredPromiseBridge({
67+
onScanRequested,
68+
onScanRejected,
69+
});
70+
onScanRequested.mockImplementation(() => {
71+
bridge.rejectPendingScan(error);
72+
});
73+
74+
await expect(
75+
bridge.requestScan({
76+
type: QrScanRequestType.PAIR,
77+
}),
78+
).rejects.toThrow(error);
79+
expect(onScanRejected).toHaveBeenCalledWith(error);
80+
});
81+
82+
it('throws an error if no pending scan exists', () => {
83+
const bridge = new QrKeyringDeferredPromiseBridge();
84+
85+
expect(() => {
86+
bridge.rejectPendingScan(new Error('Test error'));
87+
}).toThrow('No pending scan to reject.');
88+
});
89+
});
90+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { type DeferredPromise, createDeferredPromise } from '@metamask/utils';
2+
import { Mutex } from 'async-mutex';
3+
4+
import type {
5+
QrKeyringBridge,
6+
QrScanRequest,
7+
SerializedUR,
8+
} from './qr-keyring';
9+
10+
export type QrKeyringScannerOptions = {
11+
/**
12+
* Callback invoked when a scan request is made.
13+
* This can be used to trigger the actual scanning process.
14+
*/
15+
onScanRequested?: (request: QrScanRequest) => void;
16+
/**
17+
* Callback invoked when a scan is successfully resolved.
18+
* This can be used to handle the result of the scan.
19+
*/
20+
onScanResolved?: (result: SerializedUR) => void;
21+
/**
22+
* Callback invoked when a scan is rejected with an error.
23+
* This can be used to handle errors that occur during the scan.
24+
*/
25+
onScanRejected?: (error: Error) => void;
26+
};
27+
28+
/**
29+
* A bridge that turns the scan request into a deferred promise, allowing
30+
* the consumer to control the resolution and rejection of the scan.
31+
*/
32+
export class QrKeyringDeferredPromiseBridge implements QrKeyringBridge {
33+
readonly #lock = new Mutex();
34+
35+
readonly #onScanRequested?: ((request: QrScanRequest) => void) | undefined;
36+
37+
readonly #onScanResolved?: ((result: SerializedUR) => void) | undefined;
38+
39+
readonly #onScanRejected?: ((error: Error) => void) | undefined;
40+
41+
#pendingScan?: DeferredPromise<SerializedUR> | null;
42+
43+
constructor({
44+
onScanRequested,
45+
onScanResolved,
46+
onScanRejected,
47+
}: QrKeyringScannerOptions = {}) {
48+
this.#onScanRequested = onScanRequested;
49+
this.#onScanResolved = onScanResolved;
50+
this.#onScanRejected = onScanRejected;
51+
}
52+
53+
/**
54+
* Request a QR code scan, obtaining a CBOR and a type as response.
55+
*
56+
* @param request - The type of QR scan request.
57+
* @returns A promise that resolves with the scanned data as a serialized UR.
58+
*/
59+
async requestScan(request: QrScanRequest): Promise<SerializedUR> {
60+
return this.#lock.runExclusive(async () => {
61+
const deferredPromise = createDeferredPromise<SerializedUR>();
62+
this.#pendingScan = deferredPromise;
63+
this.#onScanRequested?.(request);
64+
return deferredPromise.promise;
65+
});
66+
}
67+
68+
/**
69+
* Resolve the pending scan with the given result.
70+
*
71+
* @param result - The scanned data as a serialized UR.
72+
*/
73+
resolvePendingScan(result: SerializedUR): void {
74+
if (!this.#pendingScan) {
75+
throw new Error('No pending scan to resolve.');
76+
}
77+
this.#pendingScan.resolve(result);
78+
this.#pendingScan = null;
79+
this.#onScanResolved?.(result);
80+
}
81+
82+
/**
83+
* Reject the pending scan with the given error.
84+
*
85+
* @param error - The error to reject the scan with.
86+
*/
87+
rejectPendingScan(error: Error): void {
88+
if (!this.#pendingScan) {
89+
throw new Error('No pending scan to reject.');
90+
}
91+
this.#pendingScan.reject(error);
92+
this.#pendingScan = null;
93+
this.#onScanRejected?.(error);
94+
}
95+
}

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1732,6 +1732,7 @@ __metadata:
17321732
"@types/hdkey": "npm:^2.0.1"
17331733
"@types/jest": "npm:^29.5.12"
17341734
"@types/node": "npm:^20.12.12"
1735+
async-mutex: "npm:^0.5.0"
17351736
deepmerge: "npm:^4.2.2"
17361737
depcheck: "npm:^1.4.7"
17371738
hdkey: "npm:^2.1.0"

0 commit comments

Comments
 (0)