diff --git a/ui/contexts/hardware-wallets/__mocks__/webConnectionUtils.ts b/ui/contexts/hardware-wallets/__mocks__/webConnectionUtils.ts index 30fb7d0208d2..a91bd75bdb9c 100644 --- a/ui/contexts/hardware-wallets/__mocks__/webConnectionUtils.ts +++ b/ui/contexts/hardware-wallets/__mocks__/webConnectionUtils.ts @@ -1,3 +1,4 @@ +import { CameraPermissionState } from '../constants'; import { HardwareWalletType, HardwareConnectionPermissionState, @@ -10,11 +11,15 @@ import { // Mock functions export const isWebHidAvailable = jest.fn(); export const isWebUsbAvailable = jest.fn(); +export const isCameraAvailable = jest.fn(); export const checkWebHidPermission = jest.fn(); export const checkWebUsbPermission = jest.fn(); +export const checkCameraPermissionState = jest.fn(); +export const checkCameraPermission = jest.fn(); export const checkHardwareWalletPermission = jest.fn(); export const requestWebHidPermission = jest.fn(); export const requestWebUsbPermission = jest.fn(); +export const requestCameraPermission = jest.fn(); export const requestHardwareWalletPermission = jest.fn(); export const getConnectedDevices = jest.fn(); export const subscribeToWebHidEvents = jest.fn(); @@ -24,6 +29,7 @@ export const subscribeToHardwareWalletEvents = jest.fn(); // Default mock implementations isWebHidAvailable.mockReturnValue(true); isWebUsbAvailable.mockReturnValue(true); +isCameraAvailable.mockReturnValue(true); checkWebHidPermission.mockResolvedValue( HardwareConnectionPermissionState.Granted, ); @@ -37,18 +43,26 @@ checkHardwareWalletPermission.mockImplementation( return Promise.resolve(HardwareConnectionPermissionState.Granted); case HardwareWalletType.Trezor: return Promise.resolve(HardwareConnectionPermissionState.Granted); + case HardwareWalletType.Qr: + return Promise.resolve(HardwareConnectionPermissionState.Granted); default: return Promise.resolve(HardwareConnectionPermissionState.Denied); } }, ); +checkCameraPermissionState.mockResolvedValue( + HardwareConnectionPermissionState.Granted, +); +checkCameraPermission.mockResolvedValue(CameraPermissionState.Granted); requestWebHidPermission.mockResolvedValue(true); requestWebUsbPermission.mockResolvedValue(true); +requestCameraPermission.mockResolvedValue(true); requestHardwareWalletPermission.mockImplementation( (walletType: HardwareWalletType) => { switch (walletType) { case HardwareWalletType.Ledger: case HardwareWalletType.Trezor: + case HardwareWalletType.Qr: return Promise.resolve(true); default: return Promise.resolve(false); @@ -64,6 +78,7 @@ subscribeToHardwareWalletEvents.mockReturnValue(jest.fn()); export const resetwebConnectionUtilsMocks = () => { isWebHidAvailable.mockReturnValue(true); isWebUsbAvailable.mockReturnValue(true); + isCameraAvailable.mockReturnValue(true); checkWebHidPermission.mockResolvedValue( HardwareConnectionPermissionState.Granted, ); @@ -76,19 +91,26 @@ export const resetwebConnectionUtilsMocks = () => { case HardwareWalletType.Ledger: return Promise.resolve(HardwareConnectionPermissionState.Granted); case HardwareWalletType.Trezor: + case HardwareWalletType.Qr: return Promise.resolve(HardwareConnectionPermissionState.Granted); default: return Promise.resolve(HardwareConnectionPermissionState.Denied); } }, ); + checkCameraPermissionState.mockResolvedValue( + HardwareConnectionPermissionState.Granted, + ); + checkCameraPermission.mockResolvedValue(CameraPermissionState.Granted); requestWebHidPermission.mockResolvedValue(true); requestWebUsbPermission.mockResolvedValue(true); + requestCameraPermission.mockResolvedValue(true); requestHardwareWalletPermission.mockImplementation( (walletType: HardwareWalletType) => { switch (walletType) { case HardwareWalletType.Ledger: case HardwareWalletType.Trezor: + case HardwareWalletType.Qr: return Promise.resolve(true); default: return Promise.resolve(false); @@ -114,11 +136,16 @@ export const mockPermissionsDenied = () => { checkWebUsbPermission.mockResolvedValue( HardwareConnectionPermissionState.Denied, ); + checkCameraPermissionState.mockResolvedValue( + HardwareConnectionPermissionState.Denied, + ); + checkCameraPermission.mockResolvedValue(CameraPermissionState.Denied); checkHardwareWalletPermission.mockResolvedValue( HardwareConnectionPermissionState.Denied, ); requestWebHidPermission.mockResolvedValue(false); requestWebUsbPermission.mockResolvedValue(false); + requestCameraPermission.mockResolvedValue(false); requestHardwareWalletPermission.mockResolvedValue(false); }; @@ -130,6 +157,10 @@ export const mockPermissionsPrompt = () => { checkWebUsbPermission.mockResolvedValue( HardwareConnectionPermissionState.Prompt, ); + checkCameraPermissionState.mockResolvedValue( + HardwareConnectionPermissionState.Prompt, + ); + checkCameraPermission.mockResolvedValue(CameraPermissionState.Prompt); checkHardwareWalletPermission.mockResolvedValue( HardwareConnectionPermissionState.Prompt, ); diff --git a/ui/contexts/hardware-wallets/adapters/QrAdapter.test.ts b/ui/contexts/hardware-wallets/adapters/QrAdapter.test.ts new file mode 100644 index 000000000000..78634d2b386c --- /dev/null +++ b/ui/contexts/hardware-wallets/adapters/QrAdapter.test.ts @@ -0,0 +1,135 @@ +import { ErrorCode, HardwareWalletError } from '@metamask/hw-wallet-sdk'; +import { CameraPermissionState } from '../constants'; +import { DeviceEvent, type HardwareWalletAdapterOptions } from '../types'; +import * as webConnectionUtils from '../webConnectionUtils'; +import { QrAdapter } from './QrAdapter'; + +jest.mock('../webConnectionUtils', () => ({ + ...jest.requireActual('../webConnectionUtils'), + checkCameraPermission: jest.fn(), +})); + +const mockCheckCameraPermission = + webConnectionUtils.checkCameraPermission as jest.MockedFunction< + typeof webConnectionUtils.checkCameraPermission + >; + +describe('QrAdapter', () => { + let adapter: QrAdapter; + let mockOptions: HardwareWalletAdapterOptions; + + const createMockOptions = (): HardwareWalletAdapterOptions => ({ + onDisconnect: jest.fn(), + onAwaitingConfirmation: jest.fn(), + onDeviceLocked: jest.fn(), + onAppNotOpen: jest.fn(), + onDeviceEvent: jest.fn(), + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockOptions = createMockOptions(); + adapter = new QrAdapter(mockOptions); + }); + + afterEach(() => { + jest.resetAllMocks(); + adapter.destroy(); + }); + + it('connect marks adapter as connected', async () => { + await adapter.connect(); + expect(adapter.isConnected()).toBe(true); + }); + + it('disconnect emits disconnected event', async () => { + await adapter.connect(); + await adapter.disconnect(); + + expect(adapter.isConnected()).toBe(false); + expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith({ + event: DeviceEvent.Disconnected, + }); + }); + + it('disconnect calls onDisconnect when onDeviceEvent throws', async () => { + await adapter.connect(); + const handlerError = new Error('onDeviceEvent failed'); + jest.mocked(mockOptions.onDeviceEvent).mockImplementation(() => { + throw handlerError; + }); + + await adapter.disconnect(); + + expect(mockOptions.onDisconnect).toHaveBeenCalledWith(handlerError); + }); + + it('ensureDeviceReady returns true when camera permission is granted', async () => { + mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Granted); + await expect(adapter.ensureDeviceReady()).resolves.toBe(true); + }); + + it('ensureDeviceReady does not call connect again when already connected', async () => { + mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Granted); + await adapter.connect(); + const connectSpy = jest.spyOn(adapter, 'connect'); + + await expect(adapter.ensureDeviceReady()).resolves.toBe(true); + + expect(connectSpy).not.toHaveBeenCalled(); + connectSpy.mockRestore(); + }); + + it('ensureDeviceReady throws PermissionCameraDenied when camera permission is denied', async () => { + mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Denied); + + await expect(adapter.ensureDeviceReady()).rejects.toThrow( + HardwareWalletError, + ); + + expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: DeviceEvent.ConnectionFailed, + error: expect.objectContaining({ + code: ErrorCode.PermissionCameraDenied, + }), + }), + ); + }); + + it('ensureDeviceReady throws PermissionCameraPromptDismissed when camera permission is prompt', async () => { + mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Prompt); + + await expect(adapter.ensureDeviceReady()).rejects.toThrow( + HardwareWalletError, + ); + + expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: DeviceEvent.ConnectionFailed, + error: expect.objectContaining({ + code: ErrorCode.PermissionCameraPromptDismissed, + }), + }), + ); + }); + + it('maps unexpected errors to hardware wallet errors and emits device event', async () => { + mockCheckCameraPermission.mockRejectedValue( + new Error('Unable to read camera permission'), + ); + + await expect(adapter.ensureDeviceReady()).rejects.toThrow( + HardwareWalletError, + ); + + expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: DeviceEvent.ConnectionFailed, + error: expect.objectContaining({ + code: ErrorCode.Unknown, + }), + }), + ); + }); +}); diff --git a/ui/contexts/hardware-wallets/adapters/QrAdapter.ts b/ui/contexts/hardware-wallets/adapters/QrAdapter.ts new file mode 100644 index 000000000000..371585fba291 --- /dev/null +++ b/ui/contexts/hardware-wallets/adapters/QrAdapter.ts @@ -0,0 +1,120 @@ +import { ErrorCode, type HardwareWalletError } from '@metamask/hw-wallet-sdk'; +import { createHardwareWalletError, getDeviceEventForError } from '../errors'; +import { toHardwareWalletError } from '../rpcErrorUtils'; +import { + DeviceEvent, + HardwareWalletType, + type EnsureDeviceReadyOptions, + type HardwareWalletAdapter, + type HardwareWalletAdapterOptions, +} from '../types'; +import { CameraPermissionState } from '../constants'; +import { checkCameraPermission } from '../webConnectionUtils'; + +/** + * QR hardware wallet adapter. + * + * Readiness depends on camera availability and permission state for QR scanning. + */ +export class QrAdapter implements HardwareWalletAdapter { + private readonly options: HardwareWalletAdapterOptions; + + private connected = false; + + constructor(options: HardwareWalletAdapterOptions) { + this.options = options; + } + + /** + * Marks the adapter as connected. + */ + async connect(): Promise { + this.connected = true; + } + + /** + * Clears connection state and notifies listeners that the QR flow is no longer active. + */ + async disconnect(): Promise { + try { + this.connected = false; + this.options.onDeviceEvent({ + event: DeviceEvent.Disconnected, + }); + } catch (error) { + this.options.onDisconnect(error); + } + } + + /** + * Whether the adapter considers the QR account flow active (after connection). + */ + isConnected(): boolean { + return this.connected; + } + + /** + * Resets local connection state. + */ + destroy(): void { + this.connected = false; + } + + /** + * Emits a device event for the given error and returns a rejected promise with that error. + * + * @param hwError - Structured hardware wallet error to surface to the UI layer. + * @returns A promise that rejects with `hwError`. + */ + private failEnsureDeviceReady(hwError: HardwareWalletError): Promise { + this.options.onDeviceEvent({ + event: getDeviceEventForError(hwError.code), + error: hwError, + }); + return Promise.reject(hwError); + } + + /** + * Ensures camera permission state allows QR scanning (via Permissions API probe). + * Rejects with `HardwareWalletError` when permission is denied, still prompt, or the probe fails. + * + * @param _options - Reserved for parity with other hardware adapters; ignored for QR. + * @returns True when camera permission is granted. + */ + async ensureDeviceReady( + _options?: EnsureDeviceReadyOptions, + ): Promise { + if (!this.isConnected()) { + await this.connect(); + } + + let permissionState: PermissionState; + try { + permissionState = await checkCameraPermission(); + } catch (error) { + return this.failEnsureDeviceReady( + toHardwareWalletError(error, HardwareWalletType.Qr), + ); + } + + if (permissionState === CameraPermissionState.Granted) { + return true; + } + + if (permissionState === CameraPermissionState.Denied) { + return this.failEnsureDeviceReady( + createHardwareWalletError( + ErrorCode.PermissionCameraDenied, + HardwareWalletType.Qr, + ), + ); + } + + return this.failEnsureDeviceReady( + createHardwareWalletError( + ErrorCode.PermissionCameraPromptDismissed, + HardwareWalletType.Qr, + ), + ); + } +} diff --git a/ui/contexts/hardware-wallets/adapters/factory.test.ts b/ui/contexts/hardware-wallets/adapters/factory.test.ts index c92b2288c063..1f9a01aad732 100644 --- a/ui/contexts/hardware-wallets/adapters/factory.test.ts +++ b/ui/contexts/hardware-wallets/adapters/factory.test.ts @@ -5,6 +5,7 @@ import { import { createAdapterForHardwareWalletType } from './factory'; import { LedgerAdapter } from './LedgerAdapter'; import { NonHardwareAdapter } from './NonHardwareAdapter'; +import { QrAdapter } from './QrAdapter'; describe('createAdapterForHardwareWalletType', () => { const mockOptions: HardwareWalletAdapterOptions = { @@ -27,6 +28,14 @@ describe('createAdapterForHardwareWalletType', () => { ); expect(adapter).toBeInstanceOf(LedgerAdapter); }); + + it('creates QrAdapter for QR wallet type', () => { + const adapter = createAdapterForHardwareWalletType( + HardwareWalletType.Qr, + mockOptions, + ); + expect(adapter).toBeInstanceOf(QrAdapter); + }); }); describe('non-hardware wallet accounts', () => { diff --git a/ui/contexts/hardware-wallets/adapters/factory.ts b/ui/contexts/hardware-wallets/adapters/factory.ts index e502421033cc..1719025c7ae8 100644 --- a/ui/contexts/hardware-wallets/adapters/factory.ts +++ b/ui/contexts/hardware-wallets/adapters/factory.ts @@ -5,6 +5,7 @@ import { } from '../types'; import { LedgerAdapter } from './LedgerAdapter'; import { NonHardwareAdapter } from './NonHardwareAdapter'; +import { QrAdapter } from './QrAdapter'; /** * Creates an adapter for the given hardware wallet type. @@ -20,6 +21,8 @@ export function createAdapterForHardwareWalletType( switch (walletType) { case HardwareWalletType.Ledger: return new LedgerAdapter(adapterOptions); + case HardwareWalletType.Qr: + return new QrAdapter(adapterOptions); default: return new NonHardwareAdapter(adapterOptions); } diff --git a/ui/contexts/hardware-wallets/adapters/index.ts b/ui/contexts/hardware-wallets/adapters/index.ts index dc0b1e286ac2..78283b3e3055 100644 --- a/ui/contexts/hardware-wallets/adapters/index.ts +++ b/ui/contexts/hardware-wallets/adapters/index.ts @@ -1,2 +1,3 @@ export { LedgerAdapter } from './LedgerAdapter'; export { NonHardwareAdapter } from './NonHardwareAdapter'; +export { QrAdapter } from './QrAdapter'; diff --git a/ui/contexts/hardware-wallets/constants.ts b/ui/contexts/hardware-wallets/constants.ts index 9b7f98c4f27b..7facd8d83cf6 100644 --- a/ui/contexts/hardware-wallets/constants.ts +++ b/ui/contexts/hardware-wallets/constants.ts @@ -1 +1,11 @@ export const HARDWARE_WALLET_ERROR_MODAL_NAME = 'HARDWARE_WALLET_ERROR'; + +/** + * Named values for the browser `PermissionStatus.state` union when querying the + * `camera` permission ({@link https://developer.mozilla.org/en-US/docs/Web/API/PermissionStatus/state}). + */ +export const CameraPermissionState = { + Granted: 'granted', + Denied: 'denied', + Prompt: 'prompt', +} as const; diff --git a/ui/contexts/hardware-wallets/errors.ts b/ui/contexts/hardware-wallets/errors.ts index 5322abb327ac..ed5a059c79ca 100644 --- a/ui/contexts/hardware-wallets/errors.ts +++ b/ui/contexts/hardware-wallets/errors.ts @@ -4,6 +4,7 @@ import { Severity, Category, LEDGER_ERROR_MAPPINGS, + QR_WALLET_ERROR_MAPPINGS, } from '@metamask/hw-wallet-sdk'; import { ConnectionState } from './connectionState'; import { @@ -86,6 +87,7 @@ const ERROR_PROPERTIES_MAP = (() => { // Extract from Ledger extractFromMappings(LEDGER_ERROR_MAPPINGS); + extractFromMappings(QR_WALLET_ERROR_MAPPINGS); return map; })(); diff --git a/ui/contexts/hardware-wallets/webConnectionUtils.test.ts b/ui/contexts/hardware-wallets/webConnectionUtils.test.ts index 8d862e184b60..ed75d4f8a7cf 100644 --- a/ui/contexts/hardware-wallets/webConnectionUtils.test.ts +++ b/ui/contexts/hardware-wallets/webConnectionUtils.test.ts @@ -2,21 +2,27 @@ import { LEDGER_USB_VENDOR_ID, TREZOR_USB_VENDOR_IDS, } from '../../../shared/constants/hardware-wallets'; +import { CameraPermissionState } from './constants'; import { HardwareWalletType, HardwareConnectionPermissionState } from './types'; import { isWebHidAvailable, isWebUsbAvailable, + isCameraAvailable, checkHardwareWalletPermission, checkWebHidPermission, checkWebUsbPermission, + checkCameraPermissionState, + checkCameraPermission, requestHardwareWalletPermission, requestWebHidPermission, requestWebUsbPermission, + requestCameraPermission, getConnectedLedgerDevices, getConnectedTrezorDevices, getConnectedDevices, subscribeToWebHidEvents, subscribeToWebUsbEvents, + subscribeToHardwareWalletEvents, } from './webConnectionUtils'; // Default device identifiers for testing @@ -113,6 +119,20 @@ describe('webConnectionUtils', () => { }, configurable: true, }); + + Object.defineProperty(window.navigator, 'mediaDevices', { + value: { + getUserMedia: jest.fn(), + }, + configurable: true, + }); + + Object.defineProperty(window.navigator, 'permissions', { + value: { + query: jest.fn(), + }, + configurable: true, + }); }; // Helper function to restore navigator @@ -162,6 +182,20 @@ describe('webConnectionUtils', () => { return window.navigator.usb as jest.Mocked; }; + const getMockedMediaDevices = (): { + getUserMedia: jest.Mock; + } => { + return window.navigator.mediaDevices as unknown as { + getUserMedia: jest.Mock; + }; + }; + + const getMockedPermissions = (): { + query: jest.Mock; + } => { + return window.navigator.permissions as unknown as { query: jest.Mock }; + }; + beforeEach(() => { jest.clearAllMocks(); setupDefaultNavigator(); @@ -267,6 +301,40 @@ describe('webConnectionUtils', () => { }); }); + describe('isCameraAvailable', () => { + it('returns true when camera APIs are available', () => { + expect(isCameraAvailable()).toBe(true); + }); + + it('returns false when window is undefined', () => { + setupUndefinedWindow(); + + expect(isCameraAvailable()).toBe(false); + + restoreWindow(); + }); + + it('returns false when mediaDevices is not available', () => { + Object.defineProperty(window.navigator, 'mediaDevices', { + value: undefined, + writable: true, + configurable: true, + }); + + expect(isCameraAvailable()).toBe(false); + }); + + it('returns false when getUserMedia is not a function', () => { + Object.defineProperty(window.navigator, 'mediaDevices', { + value: {}, + writable: true, + configurable: true, + }); + + expect(isCameraAvailable()).toBe(false); + }); + }); + describe('checkHardwareWalletPermission', () => { it('returns Granted when Ledger devices are paired', async () => { (window.navigator.hid.getDevices as jest.Mock).mockResolvedValue([ @@ -299,6 +367,127 @@ describe('webConnectionUtils', () => { expect(result).toBe(HardwareConnectionPermissionState.Denied); }); + + it('returns camera permission state for QR wallet type', async () => { + getMockedPermissions().query.mockResolvedValue({ + state: CameraPermissionState.Granted, + }); + + const result = await checkHardwareWalletPermission(HardwareWalletType.Qr); + + expect(result).toBe(HardwareConnectionPermissionState.Granted); + }); + }); + + describe('camera permission helpers', () => { + it('checkCameraPermissionState returns Granted when camera permission is granted', async () => { + getMockedPermissions().query.mockResolvedValue({ + state: CameraPermissionState.Granted, + }); + + await expect(checkCameraPermissionState()).resolves.toBe( + HardwareConnectionPermissionState.Granted, + ); + }); + + it('checkCameraPermissionState returns Denied when camera permission is denied', async () => { + getMockedPermissions().query.mockResolvedValue({ + state: CameraPermissionState.Denied, + }); + + await expect(checkCameraPermissionState()).resolves.toBe( + HardwareConnectionPermissionState.Denied, + ); + }); + + it('checkCameraPermission returns prompt when permissions API is unavailable', async () => { + // Camera capture APIs must remain available; otherwise isCameraAvailable() short-circuits + // to denied and this test would not exercise the permissions branch. + expect(isCameraAvailable()).toBe(true); + + Object.defineProperty(window.navigator, 'permissions', { + value: undefined, + writable: true, + configurable: true, + }); + + await expect(checkCameraPermission()).resolves.toBe( + CameraPermissionState.Prompt, + ); + }); + + it('checkCameraPermission returns prompt when permissions.query is missing', async () => { + expect(isCameraAvailable()).toBe(true); + + Object.defineProperty(window.navigator, 'permissions', { + value: {}, + writable: true, + configurable: true, + }); + + await expect(checkCameraPermission()).resolves.toBe( + CameraPermissionState.Prompt, + ); + }); + + it('checkCameraPermission returns denied when camera APIs are unavailable', async () => { + Object.defineProperty(window.navigator, 'mediaDevices', { + value: undefined, + writable: true, + configurable: true, + }); + + expect(isCameraAvailable()).toBe(false); + + await expect(checkCameraPermission()).resolves.toBe( + CameraPermissionState.Denied, + ); + }); + + it('checkCameraPermissionState returns Unknown when permissions.query rejects', async () => { + getMockedPermissions().query.mockRejectedValue(new Error('query failed')); + + await expect(checkCameraPermissionState()).resolves.toBe( + HardwareConnectionPermissionState.Unknown, + ); + }); + + it('checkCameraPermission rejects when permissions.query rejects', async () => { + getMockedPermissions().query.mockRejectedValue(new Error('query failed')); + + await expect(checkCameraPermission()).rejects.toThrow( + 'Unable to determine camera permission state', + ); + }); + + it('requestCameraPermission returns true and closes stream tracks on success', async () => { + const stop = jest.fn(); + const mockTrack = { stop } as unknown as MediaStreamTrack; + getMockedMediaDevices().getUserMedia.mockResolvedValue({ + getTracks: () => [mockTrack], + }); + + await expect(requestCameraPermission()).resolves.toBe(true); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('requestCameraPermission returns false when user denies camera access', async () => { + getMockedMediaDevices().getUserMedia.mockRejectedValue( + new Error('Permission denied'), + ); + + await expect(requestCameraPermission()).resolves.toBe(false); + }); + + it('requestCameraPermission returns false when camera APIs are unavailable', async () => { + Object.defineProperty(window.navigator, 'mediaDevices', { + value: undefined, + writable: true, + configurable: true, + }); + + await expect(requestCameraPermission()).resolves.toBe(false); + }); }); describe('checkWebHidPermission', () => { @@ -449,6 +638,18 @@ describe('webConnectionUtils', () => { expect(result).toBe(false); }); + + it('returns true when camera permission is granted for QR wallet type', async () => { + getMockedMediaDevices().getUserMedia.mockResolvedValue({ + getTracks: () => [], + }); + + const result = await requestHardwareWalletPermission( + HardwareWalletType.Qr, + ); + + expect(result).toBe(true); + }); }); describe('requestWebHidPermission', () => { @@ -1118,6 +1319,24 @@ describe('webConnectionUtils', () => { }); }); + describe('subscribeToHardwareWalletEvents', () => { + it('returns a no-op unsubscribe for QR wallet type', () => { + const mockOnConnect = jest.fn(); + const mockOnDisconnect = jest.fn(); + + const unsubscribe = subscribeToHardwareWalletEvents( + HardwareWalletType.Qr, + mockOnConnect, + mockOnDisconnect, + ); + + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + expect(getMockedHid().addEventListener).not.toHaveBeenCalled(); + expect(getMockedUsb().addEventListener).not.toHaveBeenCalled(); + }); + }); + describe('getConnectedLedgerDevices', () => { it('filters Ledger devices correctly in getConnectedLedgerDevices', async () => { const ledgerDevice = createMockHIDDevice() as HIDDevice; diff --git a/ui/contexts/hardware-wallets/webConnectionUtils.ts b/ui/contexts/hardware-wallets/webConnectionUtils.ts index 9403266ccf04..985fc6cf8ca2 100644 --- a/ui/contexts/hardware-wallets/webConnectionUtils.ts +++ b/ui/contexts/hardware-wallets/webConnectionUtils.ts @@ -2,6 +2,7 @@ import { LEDGER_USB_VENDOR_ID, TREZOR_USB_VENDOR_IDS, } from '../../../shared/constants/hardware-wallets'; +import { CameraPermissionState } from './constants'; import { HardwareWalletType, HardwareConnectionPermissionState } from './types'; /** @@ -26,6 +27,49 @@ export function isWebUsbAvailable(): boolean { ); } +/** + * Check if camera APIs are available in the current browser. + */ +export function isCameraAvailable(): boolean { + if (globalThis.window === undefined) { + return false; + } + const { navigator } = globalThis; + return ( + navigator.mediaDevices !== undefined && + typeof navigator.mediaDevices.getUserMedia === 'function' + ); +} + +/** Message for the error thrown by {@link checkCameraPermission} when the probe fails. */ +const CAMERA_PERMISSION_PROBE_FAILED_MESSAGE = + 'Unable to determine camera permission state'; + +/** + * Shared probe for the browser camera permission. Returns `null` when + * `permissions.query` throws so callers can map to `Unknown` vs surface an error. + */ +async function queryCameraPermissionDomState(): Promise { + if (!isCameraAvailable()) { + return CameraPermissionState.Denied; + } + + try { + const { navigator } = globalThis; + const { permissions } = navigator; + if (!permissions?.query) { + return CameraPermissionState.Prompt; + } + + const result = await permissions.query({ + name: 'camera' as PermissionName, + }); + return result.state; + } catch { + return null; + } +} + /** * Check if a device matches the vendor filters for a specific hardware wallet type * @@ -84,11 +128,32 @@ export async function checkHardwareWalletPermission( return await checkWebHidPermission(walletType); case HardwareWalletType.Trezor: return await checkWebUsbPermission(walletType); + case HardwareWalletType.Qr: + return await checkCameraPermissionState(); default: return HardwareConnectionPermissionState.Denied; } } +/** + * Check camera permission state for hardware-wallet permission UI. + */ +export async function checkCameraPermissionState(): Promise { + const domState = await queryCameraPermissionDomState(); + if (domState === null) { + return HardwareConnectionPermissionState.Unknown; + } + + switch (domState) { + case CameraPermissionState.Granted: + return HardwareConnectionPermissionState.Granted; + case CameraPermissionState.Denied: + return HardwareConnectionPermissionState.Denied; + default: + return HardwareConnectionPermissionState.Prompt; + } +} + /** * Check if WebHID permission is granted by checking for paired devices * @@ -165,11 +230,48 @@ export async function requestHardwareWalletPermission( return requestWebHidPermission(walletType); case HardwareWalletType.Trezor: return requestWebUsbPermission(walletType); + case HardwareWalletType.Qr: + return requestCameraPermission(); default: return false; } } +/** + * Request camera permission from the user. + */ +export async function requestCameraPermission(): Promise { + if (!isCameraAvailable()) { + return false; + } + + try { + const { navigator } = globalThis; + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + + stream.getTracks().forEach((track) => track.stop()); + return true; + } catch { + return false; + } +} + +/** + * Return browser camera permission state for adapter readiness checks. + * + * @throws {Error} When the permission probe fails (equivalent to + * `HardwareConnectionPermissionState.Unknown` from `checkCameraPermissionState`). + */ +export async function checkCameraPermission(): Promise { + const domState = await queryCameraPermissionDomState(); + if (domState === null) { + throw new Error(CAMERA_PERMISSION_PROBE_FAILED_MESSAGE); + } + return domState; +} + /** * Request WebHID permission from the user * This will show the browser's device selection dialog