diff --git a/JUSTFILE b/JUSTFILE index e5bad34a6..df192aa99 100644 --- a/JUSTFILE +++ b/JUSTFILE @@ -2,9 +2,13 @@ default: @just --list mprocs: mprocs -c mprocs.yml +rotate: + mprocs -c mprocs-rotate.yml restart: docker compose down && echo 'Removing fm dirs' && sudo rm -rf fm_* && echo 'Done' && mprocs -c mprocs.yml gateway: yarn nix-gateway guardian: yarn nix-guardian +reset dc: + cp original-docker-compose.yml docker-compose.yml && docker compose down -v && echo 'Removing fm dirs' && sudo rm -rf fm_* && echo 'Done' diff --git a/README.md b/README.md index 42191d52f..284008c58 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ This project includes the following apps / packages: - `eslint-config`: Shared `eslint` configurations (includes `eslint-plugin-react` and `eslint-config-prettier`) - `tsconfig`: Shared `tsconfig.json`s used throughout Fedimint UI apps +## Testing + +For detailed testing instructions, please refer to the [testing.md](./testing.md) file in this repository. It contains comprehensive information on how to set up and run local tests for the Fedimint UI projects. + ## Version Policy Fedimint UI releases use semantic versioning (`major.minor.patch`) diff --git a/apps/guardian-ui/package.json b/apps/guardian-ui/package.json index a265282e3..a9eaf29d9 100644 --- a/apps/guardian-ui/package.json +++ b/apps/guardian-ui/package.json @@ -26,6 +26,7 @@ "framer-motion": "^6", "jsonrpc-client-websocket": "^1.5.2", "node": "^20.1.0", + "qr-scanner": "^1.4.2", "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/apps/guardian-ui/src/GuardianApi.ts b/apps/guardian-ui/src/GuardianApi.ts index 1071f2f71..c391c2b45 100644 --- a/apps/guardian-ui/src/GuardianApi.ts +++ b/apps/guardian-ui/src/GuardianApi.ts @@ -285,7 +285,7 @@ export class GuardianApi { public signApiAnnouncement = async ( newUrl: string ): Promise => { - return this.call(AdminRpc.signApiAnnouncement, { new_url: newUrl }); + return this.call(AdminRpc.signApiAnnouncement, newUrl); }; public shutdown = async (session?: number): Promise => { @@ -314,14 +314,14 @@ export class GuardianApi { ): Promise => { try { const websocket = await this.connect(); - console.log('method', method); + // console.log('method', method); const response = await websocket.call(method, [ { auth: this.getPassword() || null, params, }, ]); - console.log('response', response); + // console.log('response', response); if (response.error) { throw response.error; diff --git a/apps/guardian-ui/src/admin/FederationAdmin.tsx b/apps/guardian-ui/src/admin/FederationAdmin.tsx index c89dacd2d..0b2132022 100644 --- a/apps/guardian-ui/src/admin/FederationAdmin.tsx +++ b/apps/guardian-ui/src/admin/FederationAdmin.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { Flex, Box, Heading, Skeleton } from '@chakra-ui/react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Flex, Box, Heading, Skeleton, useDisclosure } from '@chakra-ui/react'; import { ClientConfig, SignedApiAnnouncement, @@ -15,6 +15,38 @@ import { InviteCode } from '../components/dashboard/admin/InviteCode'; import { FederationTabsCard } from '../components/dashboard/tabs/FederationTabsCard'; import { BftInfo } from '../components/BftInfo'; import { DangerZone } from '../components/dashboard/danger/DangerZone'; +import { SignApiAnnouncement } from '../components/dashboard/danger/SignApiAnnouncement'; +import { normalizeUrl } from '../utils'; + +const checkAnnouncementNeeded = ( + currentApiUrl: string, + currentAnnouncement: SignedApiAnnouncement['api_announcement'] | undefined, + setAnnouncementNeeded: React.Dispatch>, + onOpen: () => void +) => { + try { + if (currentAnnouncement) { + console.log('currentAnnouncement', currentAnnouncement.api_url); + console.log('currentApiUrl', currentApiUrl); + const announcementMatches = + normalizeUrl(currentAnnouncement.api_url) === + normalizeUrl(currentApiUrl); + setAnnouncementNeeded(!announcementMatches); + if (!announcementMatches) { + onOpen(); + } + } + } catch (error) { + console.error('Error checking announcement:', error); + } +}; + +const findOurPeerId = ( + configPeerIds: number[], + statusPeerIds: number[] +): number | undefined => { + return configPeerIds.find((id) => !statusPeerIds.includes(id)); +}; export const FederationAdmin: React.FC = () => { const { api } = useAdminContext(); @@ -26,40 +58,65 @@ export const FederationAdmin: React.FC = () => { >({}); const [ourPeer, setOurPeer] = useState<{ id: number; name: string }>(); const [latestSession, setLatestSession] = useState(); + // API announcement modal + const { isOpen, onOpen, onClose } = useDisclosure(); + const [announcementNeeded, setAnnouncementNeeded] = useState(false); + const checkAnnouncementRef = useRef(false); - // Extracting our peer ID and name from intersection of config and status useEffect(() => { - if (config && status?.federation) { - const peerIds = Object.keys(status.federation.status_by_peer).map((id) => - parseInt(id, 10) + if (announcementNeeded) { + onOpen(); + } + }, [announcementNeeded, onOpen]); + + const fetchData = useCallback(() => { + api.inviteCode().then(setInviteCode).catch(console.error); + api.config().then(setConfig).catch(console.error); + api.apiAnnouncements().then(setSignedApiAnnouncements).catch(console.error); + api + .status() + .then((statusData) => { + setStatus(statusData); + setLatestSession(statusData?.federation?.session_count); + }) + .catch(console.error); + }, [api]); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 5000); + return () => clearInterval(interval); + }, [fetchData]); + + useEffect(() => { + if (config && status?.federation && !checkAnnouncementRef.current) { + const statusPeerIds = Object.keys(status.federation.status_by_peer).map( + (id) => parseInt(id, 10) ); const configPeerIds = Object.keys(config.global.api_endpoints).map((id) => parseInt(id, 10) ); - // Finding our peer ID as the one present in config but not in status - const ourPeerId = configPeerIds.find((id) => !peerIds.includes(id)); + + const ourPeerId = findOurPeerId(configPeerIds, statusPeerIds); if (ourPeerId !== undefined) { setOurPeer({ id: ourPeerId, name: config.global.api_endpoints[ourPeerId].name, }); + const currentApiUrl = process.env.REACT_APP_FM_CONFIG_API || ''; + const currentAnnouncement = + signedApiAnnouncements[ourPeerId.toString()]?.api_announcement; + + checkAnnouncementNeeded( + currentApiUrl, + currentAnnouncement, + setAnnouncementNeeded, + onOpen + ); + checkAnnouncementRef.current = true; } - const latestSession = status?.federation?.session_count; - setLatestSession(latestSession); } - }, [config, status]); - - useEffect(() => { - api.inviteCode().then(setInviteCode).catch(console.error); - api.config().then(setConfig).catch(console.error); - const fetchStatus = () => { - api.status().then(setStatus).catch(console.error); - }; - api.apiAnnouncements().then(setSignedApiAnnouncements).catch(console.error); - fetchStatus(); - const interval = setInterval(fetchStatus, 5000); - return () => clearInterval(interval); - }, [api]); + }, [config, status, signedApiAnnouncements, onOpen]); return ( @@ -109,16 +166,30 @@ export const FederationAdmin: React.FC = () => { signedApiAnnouncements={signedApiAnnouncements} /> - {ourPeer ? ( - - ) : null} + {ourPeer && ( + + )} + {ourPeer && ( + + )} ); }; diff --git a/apps/guardian-ui/src/assets/svgs/scan.svg b/apps/guardian-ui/src/assets/svgs/scan.svg new file mode 100644 index 000000000..d10f70eeb --- /dev/null +++ b/apps/guardian-ui/src/assets/svgs/scan.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/apps/guardian-ui/src/components/dashboard/admin/BitcoinNodeCard.tsx b/apps/guardian-ui/src/components/dashboard/admin/BitcoinNodeCard.tsx index ede62f8a1..50cba77bf 100644 --- a/apps/guardian-ui/src/components/dashboard/admin/BitcoinNodeCard.tsx +++ b/apps/guardian-ui/src/components/dashboard/admin/BitcoinNodeCard.tsx @@ -35,14 +35,10 @@ export const BitcoinNodeCard: React.FC = ({ modulesConfigs }) => { key: 'network', label: t('federation-dashboard.bitcoin-node.network-label'), value: walletConfig ? ( - - {walletConfig.network && ( - - )} - + ) : ( ), diff --git a/apps/guardian-ui/src/components/dashboard/admin/InviteCode.tsx b/apps/guardian-ui/src/components/dashboard/admin/InviteCode.tsx index e6e96218f..60085601d 100644 --- a/apps/guardian-ui/src/components/dashboard/admin/InviteCode.tsx +++ b/apps/guardian-ui/src/components/dashboard/admin/InviteCode.tsx @@ -18,8 +18,7 @@ import { useTranslation } from '@fedimint/utils'; import { ReactComponent as CopyIcon } from '../../../assets/svgs/copy.svg'; import { ReactComponent as QrIcon } from '../../../assets/svgs/qr.svg'; import QRCode from 'qrcode.react'; - -const QR_CODE_SIZE = 256; +import { QR_CODE_SIZE } from '../../../utils/constants'; interface InviteCodeProps { inviteCode: string; diff --git a/apps/guardian-ui/src/components/dashboard/danger/DangerZone.tsx b/apps/guardian-ui/src/components/dashboard/danger/DangerZone.tsx index dc5dee86f..8704fbc08 100644 --- a/apps/guardian-ui/src/components/dashboard/danger/DangerZone.tsx +++ b/apps/guardian-ui/src/components/dashboard/danger/DangerZone.tsx @@ -1,5 +1,13 @@ import React, { useState } from 'react'; -import { Box, Text, Flex, theme, Collapse, IconButton } from '@chakra-ui/react'; +import { + Box, + Text, + Flex, + theme, + Collapse, + IconButton, + Button, +} from '@chakra-ui/react'; import { GuardianAuthenticationCode } from './GuardianAuthenticationCode'; import { DownloadBackup } from './DownloadBackup'; import { useTranslation } from '@fedimint/utils'; @@ -7,20 +15,20 @@ import { ReactComponent as ChevronDownIcon } from '../../../assets/svgs/chevron- import { ReactComponent as ChevronUpIcon } from '../../../assets/svgs/chevron-up.svg'; import { ScheduleShutdown } from './ScheduleShutdown'; import { SignedApiAnnouncement } from '@fedimint/types'; -import { SignApiAnnouncement } from './SignApiAnnouncement'; interface DangerZoneProps { ourPeer: { id: number; name: string } | undefined; inviteCode: string; latestSession: number | undefined; signedApiAnnouncements: Record; + onApiAnnouncementOpen: () => void; } export const DangerZone: React.FC = ({ ourPeer, inviteCode, latestSession, - signedApiAnnouncements, + onApiAnnouncementOpen, }) => { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); @@ -59,7 +67,7 @@ export const DangerZone: React.FC = ({ - {ourPeer !== undefined && ( + {ourPeer && ( = ({ )} {ourPeer && ( - + )} {latestSession && } diff --git a/apps/guardian-ui/src/components/dashboard/danger/GuardianAuthenticationCode.tsx b/apps/guardian-ui/src/components/dashboard/danger/GuardianAuthenticationCode.tsx index 909e708d4..397c4e89a 100644 --- a/apps/guardian-ui/src/components/dashboard/danger/GuardianAuthenticationCode.tsx +++ b/apps/guardian-ui/src/components/dashboard/danger/GuardianAuthenticationCode.tsx @@ -13,8 +13,8 @@ import { } from '@chakra-ui/react'; import { useTranslation } from '@fedimint/utils'; import QRCode from 'qrcode.react'; +import { QR_CODE_SIZE } from '../../../utils/constants'; -const QR_CODE_SIZE = 256; const FEDIMINT_GUARDIAN_PREFIX = 'fedimint:guardian:'; type GuardianAuth = { @@ -89,7 +89,7 @@ export const GuardianAuthenticationCode: React.FC< > {t( - 'federation-dashboard.danger-zone.guardian-warning-message', + 'federation-dashboard.danger-zone.guardian-warning-message' )} {t( - 'federation-dashboard.danger-zone.acknowledge-and-download', + 'federation-dashboard.danger-zone.acknowledge-and-download' )} @@ -135,7 +135,7 @@ export const GuardianAuthenticationCode: React.FC< /> {t( - 'federation-dashboard.danger-zone.guardian-connect-warning', + 'federation-dashboard.danger-zone.guardian-connect-warning' )} diff --git a/apps/guardian-ui/src/components/dashboard/danger/SignApiAnnouncement.tsx b/apps/guardian-ui/src/components/dashboard/danger/SignApiAnnouncement.tsx index 99f820302..c2f5b4801 100644 --- a/apps/guardian-ui/src/components/dashboard/danger/SignApiAnnouncement.tsx +++ b/apps/guardian-ui/src/components/dashboard/danger/SignApiAnnouncement.tsx @@ -10,143 +10,178 @@ import { ModalCloseButton, Text, Flex, - useDisclosure, Box, Icon, Tooltip, Divider, - useTheme, + Input, } from '@chakra-ui/react'; import { useTranslation } from '@fedimint/utils'; import { SignedApiAnnouncement } from '@fedimint/types'; import { normalizeUrl } from '../../../utils'; -import { FiCheckCircle, FiAlertTriangle } from 'react-icons/fi'; +import { FiCheckCircle, FiAlertTriangle, FiEdit2 } from 'react-icons/fi'; import { useAdminContext } from '../../../hooks'; interface SignApiAnnouncementProps { + isOpen: boolean; + onClose: () => void; ourPeer: { id: number; name: string }; signedApiAnnouncements: Record; + currentApiUrl: string; } export const SignApiAnnouncement: React.FC = ({ + isOpen, + onClose, ourPeer, signedApiAnnouncements, + currentApiUrl, }) => { const { api } = useAdminContext(); const { t } = useTranslation(); - const theme = useTheme(); - const { isOpen, onOpen, onClose } = useDisclosure(); const [isSigningNew, setIsSigningNew] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [apiUrl, setApiUrl] = useState(currentApiUrl); - const currentApiUrl = process.env.REACT_APP_FM_CONFIG_API || ''; const currentAnnouncement = ourPeer - ? signedApiAnnouncements[ourPeer.id.toString()].api_announcement + ? signedApiAnnouncements[ourPeer.id.toString()]?.api_announcement : undefined; const announcementMatches = useMemo(() => { if (!currentAnnouncement) return false; - return ( - normalizeUrl(currentAnnouncement.api_url) === normalizeUrl(currentApiUrl) - ); - }, [currentAnnouncement, currentApiUrl]); + return normalizeUrl(currentAnnouncement.api_url) === normalizeUrl(apiUrl); + }, [currentAnnouncement, apiUrl]); + + const handleClose = () => { + setApiUrl(currentApiUrl); + setIsEditing(false); + onClose(); + }; + + const handleEditApiUrl = () => { + setIsEditing(true); + }; + + const handleSaveApiUrl = () => { + setIsEditing(false); + }; const handleSignNewAnnouncement = () => { setIsSigningNew(true); - api.signApiAnnouncement(currentApiUrl).then(() => { - setIsSigningNew(false); - onClose(); - }); + api + .signApiAnnouncement(apiUrl) + .then(() => { + setIsSigningNew(false); + onClose(); + }) + .catch((error) => { + setIsSigningNew(false); + console.error('Failed to sign API announcement:', error); + }); }; return ( - <> - - - - - - - {t('federation-dashboard.danger-zone.sign-api-announcement.title')} - - - - - - - {t( - 'federation-dashboard.danger-zone.sign-api-announcement.current-api-url' - )} - - {currentApiUrl} - - - - {t( - 'federation-dashboard.danger-zone.sign-api-announcement.announced-api-url' - )} - - - {currentAnnouncement?.api_url || t('common.unknown')} - - - - - + + + + {t('federation-dashboard.danger-zone.sign-api-announcement.title')} + + + + + + + {t( + 'federation-dashboard.danger-zone.sign-api-announcement.current-api-url' + )} + + {isEditing ? ( + setApiUrl(e.target.value)} + onBlur={handleSaveApiUrl} + fontFamily='mono' /> - - {announcementMatches - ? t( - 'federation-dashboard.danger-zone.sign-api-announcement.urls-match' - ) - : t( - 'federation-dashboard.danger-zone.sign-api-announcement.urls-mismatch' - )} - - + ) : ( + + + {apiUrl} + + + + )} + + + + {t( + 'federation-dashboard.danger-zone.sign-api-announcement.announced-api-url' + )} + + + {signedApiAnnouncements[ourPeer.id.toString()]?.api_announcement + ?.api_url || + t( + 'federation-dashboard.danger-zone.sign-api-announcement.no-announcement' + )} + + + + + + + {announcementMatches + ? t( + 'federation-dashboard.danger-zone.sign-api-announcement.urls-match' + ) + : t( + 'federation-dashboard.danger-zone.sign-api-announcement.urls-mismatch' + )} + - + + - - {!announcementMatches && ( - + {!announcementMatches && ( + + - - )} - - - - - + {t( + 'federation-dashboard.danger-zone.sign-api-announcement.sign-button' + )} + + + )} + + + + ); }; diff --git a/apps/guardian-ui/src/components/dashboard/tabs/ApiAnnouncements.tsx b/apps/guardian-ui/src/components/dashboard/tabs/ApiAnnouncements.tsx index d8f8cde21..2525940e0 100644 --- a/apps/guardian-ui/src/components/dashboard/tabs/ApiAnnouncements.tsx +++ b/apps/guardian-ui/src/components/dashboard/tabs/ApiAnnouncements.tsx @@ -20,15 +20,17 @@ export const ApiAnnouncements: React.FC = ({ () => [ { key: 'guardian', - heading: t('federation-dashboard.api-announcements.guardian-label'), + heading: t('federation-dashboard.api-announcements.guardian'), + width: '20%', }, { key: 'revision', - heading: t('federation-dashboard.api-announcements.revision-label'), + heading: t('federation-dashboard.api-announcements.revision'), + width: '10%', }, { key: 'apiUrl', - heading: t('federation-dashboard.api-announcements.api-url-label'), + heading: t('federation-dashboard.api-announcements.api-url'), }, ], [t] diff --git a/apps/guardian-ui/src/components/dashboard/tabs/FederationTabsCard.tsx b/apps/guardian-ui/src/components/dashboard/tabs/FederationTabsCard.tsx index d3d6da56c..a7bf40c9c 100644 --- a/apps/guardian-ui/src/components/dashboard/tabs/FederationTabsCard.tsx +++ b/apps/guardian-ui/src/components/dashboard/tabs/FederationTabsCard.tsx @@ -13,19 +13,27 @@ import { import { githubLight } from '@uiw/codemirror-theme-github'; import { json } from '@codemirror/lang-json'; import CodeMirror from '@uiw/react-codemirror'; -import { ClientConfig, MetaFields, ModuleKind } from '@fedimint/types'; +import { + ClientConfig, + MetaFields, + ModuleKind, + SignedApiAnnouncement, +} from '@fedimint/types'; import { useTranslation } from '@fedimint/utils'; import { MetaManager } from './meta/MetaManager'; import { ConsensusMetaFields } from './meta/ViewConsensusMeta'; +import { ApiAnnouncements } from './ApiAnnouncements'; interface FederationTabsCardProps { config: ClientConfig | undefined; ourPeer: { id: number; name: string }; + signedApiAnnouncements: Record; } export const FederationTabsCard: React.FC = ({ config, ourPeer, + signedApiAnnouncements, }) => { const { t } = useTranslation(); const [metaModuleId, setMetaModuleId] = useState( @@ -63,10 +71,9 @@ export const FederationTabsCard: React.FC = ({ - - {t('federation-dashboard.config.manage-meta.tab-label')} - + {t('federation-dashboard.config.manage-meta.label')} {t('federation-dashboard.config.view-config')} + {t('federation-dashboard.api-announcements.label')} @@ -95,6 +102,12 @@ export const FederationTabsCard: React.FC = ({ readOnly /> + + + diff --git a/apps/guardian-ui/src/components/setup/qr/QrModal.tsx b/apps/guardian-ui/src/components/setup/qr/QrModal.tsx new file mode 100644 index 000000000..b424cf973 --- /dev/null +++ b/apps/guardian-ui/src/components/setup/qr/QrModal.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { + Modal as ChakraModal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + Flex, + Button, + useClipboard, +} from '@chakra-ui/react'; +import QRCode from 'qrcode.react'; + +interface QrModalProps { + isOpen: boolean; + onClose: () => void; + content: string; + header: string; +} + +export const QrModal: React.FC = ({ + isOpen, + onClose, + content, + header, +}) => { + const { onCopy, hasCopied } = useClipboard(content); + + return ( + + + + {header} + + + + + + + + + + + + ); +}; diff --git a/apps/guardian-ui/src/components/setup/qr/QrScannerModal.tsx b/apps/guardian-ui/src/components/setup/qr/QrScannerModal.tsx new file mode 100644 index 000000000..015d7cc4f --- /dev/null +++ b/apps/guardian-ui/src/components/setup/qr/QrScannerModal.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { + Modal as ChakraModal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + useToast, + Button, + Flex, +} from '@chakra-ui/react'; +import { Scanner } from './Scanner'; + +interface QrScannerModalProps { + isOpen: boolean; + onClose: () => void; + onScan: (data: string) => void; + title?: string; +} + +export const QrScannerModal: React.FC = ({ + isOpen, + onClose, + onScan, + title = 'Scan QR Code', +}) => { + const toast = useToast(); + + const handleScan = (data: string) => { + onScan(data); + onClose(); + }; + const handlePaste = async () => { + try { + const text = await navigator.clipboard.readText(); + handleScan(text); + } catch (error) { + console.error('Failed to read clipboard contents: ', error); + toast({ + title: 'Paste Error', + description: 'Failed to read clipboard contents. Please try again.', + status: 'error', + duration: 5000, + isClosable: true, + }); + } + }; + + const handleError = (error: string) => { + console.error(error); + if (error.includes('play() request was interrupted')) { + toast({ + title: 'Scanner Error', + description: 'The camera stream was interrupted. Please try again.', + status: 'error', + duration: 5000, + isClosable: true, + }); + onClose(); + } + }; + + return ( + + + + {title} + + + + handleError(e.toString())} + /> + + + + + + ); +}; diff --git a/apps/guardian-ui/src/components/setup/qr/Scanner.tsx b/apps/guardian-ui/src/components/setup/qr/Scanner.tsx new file mode 100644 index 000000000..ab20f42f5 --- /dev/null +++ b/apps/guardian-ui/src/components/setup/qr/Scanner.tsx @@ -0,0 +1,79 @@ +import QrScanner from 'qr-scanner'; +import React from 'react'; + +export type ScannerProps = { + scanning: boolean; + onResult: (result: string) => void; + onError: (error: string) => void; +} & React.VideoHTMLAttributes; + +export const Scanner: React.FC = ({ + scanning, + onResult, + onError, + ...props +}) => { + const [res, setRes] = React.useState(null); + const err = React.useRef(null); + const ref = React.useRef(null); + const scannerRef = React.useRef(null); + + React.useEffect(() => { + (async () => { + if (!ref.current) return; + + if (scanning) { + scannerRef.current = new QrScanner( + ref.current, + (result) => { + if (result && res !== result.data) { + setRes(result.data); + onResult(result.data); + if (scannerRef.current) { + scannerRef.current.stop(); + scannerRef.current.$overlay?.remove(); + } + } + }, + { + onDecodeError: (error) => { + if (typeof error === 'string' && err.current !== error) { + err.current = error; + onError(error); + } else if ( + typeof error !== 'string' && + err.current !== error.message + ) { + err.current = error.message; + onError(error.message); + } + }, + highlightScanRegion: true, + highlightCodeOutline: true, + preferredCamera: 'environment', + } + ); + scannerRef.current.setInversionMode('both'); + await scannerRef.current.start(); + } else if (scannerRef.current) { + scannerRef.current.stop(); + scannerRef.current.destroy(); + setRes(null); + err.current = null; + scannerRef.current = null; + } + })(); + + return () => { + scannerRef.current?.destroy(); + }; + }, [scanning, ref, onError, onResult, setRes, res]); + + return ( +