Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: scan qr code using camera #179

Merged
merged 5 commits into from
Feb 18, 2024
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
3 changes: 3 additions & 0 deletions src/extension/enums/ErrorCodeEnum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ enum ErrorCodeEnum {
// contract (application)
InvalidABIContractError = 5000,
ReadABIContractError = 5001,

// devices
CameraError = 6000,
}

export default ErrorCodeEnum;
17 changes: 17 additions & 0 deletions src/extension/errors/CameraError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// enums
import { ErrorCodeEnum } from '../enums';

// errors
import BaseExtensionError from './BaseExtensionError';

export default class CameraError extends BaseExtensionError {
public readonly code: ErrorCodeEnum = ErrorCodeEnum.CameraError;
public readonly domExceptionType: string;
public readonly name: string = 'CameraError';

constructor(domExceptionType: string, message: string) {
super(message);

this.domExceptionType = domExceptionType;
}
}
1 change: 1 addition & 0 deletions src/extension/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as BaseExtensionError } from './BaseExtensionError';
export { default as CameraError } from './CameraError';
export { default as DecryptionError } from './DecryptionError';
export { default as EncryptionError } from './EncryptionError';
export { default as FailedToSendTransactionError } from './FailedToSendTransactionError';
Expand Down
2 changes: 2 additions & 0 deletions src/extension/hooks/useCaptureQRCode/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './useCaptureQRCode';
export * from './types';
3 changes: 3 additions & 0 deletions src/extension/hooks/useCaptureQRCode/types/IScanMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type IScanMode = 'browserWindow' | 'extensionPopup';

export default IScanMode;
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// types
import IScanMode from './IScanMode';

interface IUseCaptureQrCodeState {
resetAction: () => void;
scanning: boolean;
startScanningAction: () => void;
startScanningAction: (mode: IScanMode) => void;
stopScanningAction: () => void;
uri: string | null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export type { default as IScanMode } from './IScanMode';
export type { default as IUseCaptureQrCodeState } from './IUseCaptureQrCodeState';
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,46 @@ import { QR_CODE_SCAN_INTERVAL } from '@extension/constants';
import { useSelectLogger } from '@extension/selectors';

// types
import { ILogger } from '@common/types';
import { IUseCaptureQrCodeState } from './types';
import type { ILogger } from '@common/types';
import type { IScanMode, IUseCaptureQrCodeState } from './types';

// utils
import { captureQrCode } from './utils';
import captureQRCode from './utils/captureQRCode';

export default function useCaptureQrCode(): IUseCaptureQrCodeState {
const _functionName: string = 'useCaptureQrCode';
export default function useCaptureQRCode(): IUseCaptureQrCodeState {
const _functionName: string = 'useCaptureQRCode';
// selectors
const logger: ILogger = useSelectLogger();
// states
const [intervalId, setIntervalId] = useState<number | null>(null);
const [_, setScanMode] = useState<IScanMode | null>(null);
const [scanning, setScanning] = useState<boolean>(false);
const [uri, setUri] = useState<string | null>(null);
const [uri, setURI] = useState<string | null>(null);
// misc
const captureAction: () => Promise<void> = async () => {
const captureAction = async (mode: IScanMode) => {
let capturedURI: string;

try {
capturedURI = await captureQrCode();
capturedURI = await captureQRCode(mode);

setUri(capturedURI);
setURI(capturedURI);

return stopScanningAction();
} catch (error) {
logger.debug(`${_functionName}(): ${error.message}`);
}
};
const startScanningAction: () => void = () => {
const resetAction = () => {
setURI(null);
setScanMode(null);
stopScanningAction();
};
const startScanningAction = (mode: IScanMode) => {
setScanMode(mode);
setScanning(true);

(async () => {
await captureAction();
await captureAction(mode);

// add a three-second interval that attempts to capture a qr code on the screen
setIntervalId(
Expand All @@ -49,12 +56,12 @@ export default function useCaptureQrCode(): IUseCaptureQrCodeState {
}

// attempt to capture the qr code
await captureAction();
await captureAction(mode);
}, QR_CODE_SCAN_INTERVAL)
);
})();
};
const stopScanningAction: () => void = () => {
const stopScanningAction = () => {
if (intervalId) {
window.clearInterval(intervalId);

Expand All @@ -65,6 +72,7 @@ export default function useCaptureQrCode(): IUseCaptureQrCodeState {
};

return {
resetAction,
scanning,
startScanningAction,
stopScanningAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import jsQR, { QRCode } from 'jsqr';
import browser, { Windows } from 'webextension-polyfill';

// types
import { IScanMode } from '@extension/hooks/useCaptureQRCode';

// utils
import convertDataUriToImageData from '@extension/utils/convertDataUriToImageData';

export default async function captureQrCode(): Promise<string> {
export default async function captureQRCode(mode: IScanMode): Promise<string> {
let dataImageUrl: string;
let imageData: ImageData | null;
let result: QRCode | null;
let windows: Windows.Window[];
let window: Windows.Window | null;
let window: Windows.Window | null = null;

windows = await browser.windows.getAll();
window = windows.find((value) => value.type !== 'popup') || null; // get windows that are not popups, i.e. the extension

switch (mode) {
case 'browserWindow':
window = windows.find((value) => value.type !== 'popup') || null; // get windows that are not the extension window
break;
case 'extensionPopup':
window = windows.find((value) => value.type === 'popup') || null; // get extension window as we will be showing a video from teh webcam
break;
default:
break;
}

if (!window) {
throw new Error('unable to find browser window');
throw new Error(`unable to find browser window for scan mode "${mode}"`);
}

dataImageUrl = await browser.tabs.captureVisibleTab(window.id, {
Expand All @@ -30,7 +43,7 @@ export default async function captureQrCode(): Promise<string> {
result = jsQR(imageData.data, imageData.width, imageData.height);

if (!result) {
throw new Error('no qr code found');
throw new Error(`no qr code found for scan mode "${mode}"`);
}

return result.data;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './captureQRCode';
3 changes: 0 additions & 3 deletions src/extension/hooks/useCaptureQrCode/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/extension/hooks/useCaptureQrCode/utils/index.ts

This file was deleted.

36 changes: 36 additions & 0 deletions src/extension/modals/ScanQRCodeModal/QRCodeFrameIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Icon, IconProps } from '@chakra-ui/react';
import React, { FC } from 'react';

const QRCodeFrameIcon: FC<IconProps> = (props: IconProps) => (
<Icon viewBox="0 0 100 100" {...props}>
<path
d="M25,2 L2,2 L2,25"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>

<path
d="M2,75 L2,98 L25,98"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>

<path
d="M75,98 L98,98 L98,75"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>

<path
d="M98,25 L98,2 L75,2"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</Icon>
);

export default QRCodeFrameIcon;
85 changes: 49 additions & 36 deletions src/extension/modals/ScanQRCodeModal/ScanQRCodeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,25 @@
import { Modal, ModalContent } from '@chakra-ui/react';
import React, { FC, useEffect } from 'react';
import { Modal } from '@chakra-ui/react';
import React, { FC, useState } from 'react';

// components
import ScanQRCodeModalAccountImportContent from './ScanQRCodeModalAccountImportContent';
import ScanQRCodeModalCameraStreamContent from './ScanQRCodeModalCameraStreamContent';
import ScanQRCodeModalScanningContent from './ScanQRCodeModalScanningContent';
import ScanQRCodeModalSelectScanModeContent from './ScanQRCodeModalSelectScanModeContent';
import ScanQRCodeModalUnknownURIContent from './ScanQRCodeModalUnknownURIContent';

// constants
import { BODY_BACKGROUND_COLOR } from '@extension/constants';

// enums
import { ARC0300AuthorityEnum, ARC0300PathEnum } from '@extension/enums';

// hooks
import useCaptureQrCode from '@extension/hooks/useCaptureQrCode';
import useCaptureQRCode from '@extension/hooks/useCaptureQRCode';

// selectors
import {
useSelectLogger,
useSelectScanQRCodeModal,
} from '@extension/selectors';

// theme
import { theme } from '@extension/theme';

// types
import type { ILogger } from '@common/types';
import type {
Expand All @@ -43,25 +39,31 @@ const ScanQRCodeModal: FC<IProps> = ({ onClose }: IProps) => {
const logger: ILogger = useSelectLogger();
const isOpen: boolean = useSelectScanQRCodeModal();
// hooks
const { scanning, startScanningAction, stopScanningAction, uri } =
useCaptureQrCode();
const { resetAction, scanning, startScanningAction, uri } =
useCaptureQRCode();
// state
const [showCamera, setShowCamera] = useState<boolean>(false);
// handlers
const handleCancelClick = () => handleClose();
const handleClose = () => {
stopScanningAction();
resetAction();
onClose();
};
const handleRetryScan = () => startScanningAction();
const handlePreviousClick = () => {
resetAction();
setShowCamera(false); // close the webcam, if open
};
const handleScanBrowserWindowClick = () => {
startScanningAction('browserWindow');
};
const handleScanUsingCameraClick = async () => {
setShowCamera(true);
startScanningAction('extensionPopup');
};
// renders
const renderContent = () => {
let arc0300Schema: IARC0300BaseSchema | null;

if (scanning) {
return (
<ScanQRCodeModalScanningContent onCancelClick={handleCancelClick} />
);
}

if (uri) {
arc0300Schema = parseURIToARC0300Schema(uri, { logger });

Expand All @@ -71,8 +73,8 @@ const ScanQRCodeModal: FC<IProps> = ({ onClose }: IProps) => {
if (arc0300Schema.paths[0] === ARC0300PathEnum.Import) {
return (
<ScanQRCodeModalAccountImportContent
onCancelClick={handleCancelClick}
onComplete={handleClose}
onPreviousClick={handlePreviousClick}
schema={arc0300Schema as IARC0300AccountImportSchema}
/>
);
Expand All @@ -83,38 +85,49 @@ const ScanQRCodeModal: FC<IProps> = ({ onClose }: IProps) => {
break;
}
}

// if the uri cannot be parsed
return (
<ScanQRCodeModalUnknownURIContent
onPreviousClick={handlePreviousClick}
uri={uri}
/>
);
}

if (showCamera) {
return (
<ScanQRCodeModalCameraStreamContent
onPreviousClick={handlePreviousClick}
/>
);
}

if (scanning) {
return (
<ScanQRCodeModalScanningContent onPreviousClick={handlePreviousClick} />
);
}

return (
<ScanQRCodeModalUnknownURIContent
<ScanQRCodeModalSelectScanModeContent
onCancelClick={handleCancelClick}
onTryAgainClick={handleRetryScan}
uri={uri}
onScanBrowserWindowClick={handleScanBrowserWindowClick}
onScanUsingCameraClick={handleScanUsingCameraClick}
/>
);
};

useEffect(() => {
if (isOpen) {
startScanningAction();
}
}, [isOpen]);

return (
<Modal
isOpen={isOpen}
motionPreset="slideInBottom"
onClose={onClose}
size="full"
scrollBehavior="inside"
useInert={false} // ensure the camera screen can be captured
>
<ModalContent
backgroundColor={BODY_BACKGROUND_COLOR}
borderTopRadius={theme.radii['3xl']}
borderBottomRadius={0}
>
{renderContent()}
</ModalContent>
{renderContent()}
</Modal>
);
};
Expand Down
Loading
Loading