diff options
-rw-r--r-- | packages/taler-wallet-webextension/package.json | 2 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/wallet/QrReader.tsx | 396 | ||||
-rw-r--r-- | pnpm-lock.yaml | 14 |
3 files changed, 310 insertions, 102 deletions
diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index 076e43dc1..226ea757e 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -25,9 +25,9 @@ "@gnu-taler/taler-wallet-core": "workspace:*", "date-fns": "^2.29.2", "history": "4.10.1", + "jsqr": "^1.4.0", "preact": "10.11.3", "preact-router": "3.2.1", - "qr-scanner": "^1.4.1", "qrcode-generator": "^1.4.4", "tslib": "^2.4.0" }, diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx index 467f8bb7c..c1972823a 100644 --- a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx +++ b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -14,17 +14,25 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { classifyTalerUri, TalerUriType } from "@gnu-taler/taler-util"; +import { + classifyTalerUri, + TalerUriType, + TranslatedString, +} from "@gnu-taler/taler-util"; import { styled } from "@linaria/react"; +import { css } from "@linaria/core"; import { Fragment, h, VNode } from "preact"; -import { Ref, useEffect, useRef, useState } from "preact/hooks"; -import QrScanner from "qr-scanner"; +import { Ref, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useTranslationContext } from "../context/translation.js"; import { Alert } from "../mui/Alert.js"; import { Button } from "../mui/Button.js"; import { TextField } from "../mui/TextField.js"; +import jsQR, * as pr from "jsqr"; +import { InputFile } from "../mui/InputFile.js"; +import { Grid } from "../mui/Grid.js"; +import { notDeepEqual } from "assert"; -const QrVideo = styled.video` +const QrCanvas = css` width: 80%; margin-left: auto; margin-right: auto; @@ -32,6 +40,8 @@ const QrVideo = styled.video` background-color: black; `; +const LINE_COLOR = "#FF3B58"; + const Container = styled.div` display: flex; flex-direction: column; @@ -44,111 +54,303 @@ interface Props { onDetected: (url: string) => void; } +type XY = { x: number; y: number }; + +function drawLine( + canvas: CanvasRenderingContext2D, + begin: XY, + end: XY, + color: string, +) { + canvas.beginPath(); + canvas.moveTo(begin.x, begin.y); + canvas.lineTo(end.x, end.y); + canvas.lineWidth = 4; + canvas.strokeStyle = color; + canvas.stroke(); +} + +function drawBox(context: CanvasRenderingContext2D, code: pr.QRCode) { + drawLine( + context, + code.location.topLeftCorner, + code.location.topRightCorner, + LINE_COLOR, + ); + drawLine( + context, + code.location.topRightCorner, + code.location.bottomRightCorner, + LINE_COLOR, + ); + drawLine( + context, + code.location.bottomRightCorner, + code.location.bottomLeftCorner, + LINE_COLOR, + ); + drawLine( + context, + code.location.bottomLeftCorner, + code.location.topLeftCorner, + LINE_COLOR, + ); +} + +const SCAN_PER_SECONDS = 3; +const TIME_BETWEEN_FRAMES = 1000 / SCAN_PER_SECONDS; + +async function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function drawIntoCanvasAndGetQR( + tag: HTMLVideoElement | HTMLImageElement, + canvas: HTMLCanvasElement, +): string | undefined { + const context = canvas.getContext("2d"); + if (!context) { + throw Error("no 2d canvas context"); + } + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(tag, 0, 0, canvas.width, canvas.height); + const imgData = context.getImageData(0, 0, canvas.width, canvas.height); + const code = jsQR(imgData.data, canvas.width, canvas.height, { + inversionAttempts: "attemptBoth", + }); + if (code) { + drawBox(context, code); + return code.data; + } + return undefined; +} + +async function readNextFrame( + video: HTMLVideoElement, + canvas: HTMLCanvasElement, +): Promise<string | undefined> { + const requestFrame = + "requestVideoFrameCallback" in video + ? video.requestVideoFrameCallback.bind(video) + : requestAnimationFrame; + + return new Promise<string | undefined>((ok, bad) => { + requestFrame(() => { + try { + const code = drawIntoCanvasAndGetQR(video, canvas); + ok(code); + } catch (error) { + bad(error); + } + }); + }); +} + +async function createCanvasFromVideo( + video: HTMLVideoElement, + canvas: HTMLCanvasElement, +): Promise<string> { + const context = canvas.getContext("2d", { + willReadFrequently: true, + }); + if (!context) { + throw Error("no 2d canvas context"); + } + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + let last = Date.now(); + + let found: string | undefined = undefined; + while (!found) { + const timeSinceLast = Date.now() - last; + if (timeSinceLast < TIME_BETWEEN_FRAMES) { + await delay(TIME_BETWEEN_FRAMES - timeSinceLast); + } + last = Date.now(); + found = await readNextFrame(video, canvas); + } + video.pause(); + return found; +} + +async function createCanvasFromFile( + source: string, + canvas: HTMLCanvasElement, +): Promise<string | undefined> { + const img = new Image(300, 300); + img.src = source; + canvas.width = img.width; + canvas.height = img.height; + return new Promise<string | undefined>((ok, bad) => { + img.addEventListener("load", (e) => { + try { + const code = drawIntoCanvasAndGetQR(img, canvas); + ok(code); + } catch (error) { + bad(error); + } + }); + }); +} + +async function waitUntilReady(video: HTMLVideoElement): Promise<void> { + return new Promise((ok, bad) => { + if (video.readyState === video.HAVE_ENOUGH_DATA) { + return ok(); + } + setTimeout(waitUntilReady, 100); + }); +} + export function QrReaderPage({ onDetected }: Props): VNode { const videoRef = useRef<HTMLVideoElement>(null); - // const imageRef = useRef<HTMLImageElement>(null); - const qrScanner = useRef<QrScanner | null>(null); - const [value, onChange] = useState(""); - const [active, setActive] = useState(false); + const canvasRef = useRef<HTMLCanvasElement>(null); + const [error, setError] = useState<TranslatedString | undefined>(); + const [value, setValue] = useState(""); + const [show, setShow] = useState<"canvas" | "video" | "nothing">("nothing"); + const { i18n } = useTranslationContext(); - function start(): void { - qrScanner.current!.start(); - onChange(""); - setActive(true); - } - function stop(): void { - qrScanner.current!.stop(); - setActive(false); + function onChange(str: string) { + if (!!str) { + if (!str.startsWith("taler://")) { + setError( + i18n.str`URI is not valid. Taler URI should start with "taler://"`, + ); + } else if (classifyTalerUri(str) === TalerUriType.Unknown) { + setError(i18n.str`Unknown type of Taler URI`); + } else { + setError(undefined); + } + } else { + setError(undefined); + } + setValue(str); } - function check(v: string) { - return ( - v.startsWith("taler://") && classifyTalerUri(v) !== TalerUriType.Unknown - ); + async function startVideo() { + if (!videoRef.current || !canvasRef.current) { + return; + } + const video = videoRef.current; + if (!video || !video.played) return; + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + audio: false, + }); + setShow("video"); + setError(undefined); + video.srcObject = stream; + await video.play(); + await waitUntilReady(video); + try { + const code = await createCanvasFromVideo(video, canvasRef.current); + if (code) { + onChange(code); + setShow("canvas"); + } + stream.getTracks().forEach((e) => { + e.stop(); + }); + } catch (error) { + setError(i18n.str`something unexpected happen: ${error}`); + } } - useEffect(() => { - if (!videoRef.current) { - console.log("vide was not ready"); + async function onFileRead(fileContent: string) { + if (!canvasRef.current) { return; } - const elem = videoRef.current; - setTimeout(() => { - qrScanner.current = new QrScanner( - elem, - ({ data, cornerPoints }) => { - if (check(data)) { - onDetected(data); - return; - } - onChange(data); - stop(); - }, - { - maxScansPerSecond: 5, //default 25 - highlightScanRegion: true, - }, - ); - start(); - }, 1); - return () => { - qrScanner.current?.destroy(); - }; - }, []); - - const isValid = check(value); + setShow("nothing"); + setError(undefined); + try { + const code = await createCanvasFromFile(fileContent, canvasRef.current); + if (code) { + onChange(code); + setShow("canvas"); + } else { + setError(i18n.str`Could not found a QR code in the file`); + } + } catch (error) { + setError(i18n.str`something unexpected happen: ${error}`); + } + } + + const active = value === ""; return ( <Container> - {/* <InputFile onChange={(f) => scanImage(imageRef, f)}> - Read QR from file - </InputFile> - <div ref={imageRef} /> */} - <h1> - <i18n.Translate> - Scan a QR code or enter taler:// URI below - </i18n.Translate> - </h1> - <QrVideo ref={videoRef} /> - <TextField - label="Taler URI" - variant="standard" - fullWidth - value={value} - onChange={onChange} - /> - {isValid && ( - <Button variant="contained" onClick={async () => onDetected(value)}> - <i18n.Translate>Open</i18n.Translate> - </Button> - )} - {!active && !isValid && ( - <Fragment> - <Alert severity="error"> - <i18n.Translate> - URI is not valid. Taler URI should start with `taler://` - </i18n.Translate> - </Alert> - <Button variant="contained" onClick={async () => start()}> - <i18n.Translate>Try another</i18n.Translate> - </Button> - </Fragment> - )} + <section> + <h1> + <i18n.Translate> + Scan a QR code or enter taler:// URI below + </i18n.Translate> + </h1> + + <p> + <TextField + label="Taler URI" + variant="standard" + fullWidth + value={value} + onChange={onChange} + /> + </p> + <Grid container justifyContent="space-around" columns={2}> + <Grid item xs={2}> + <p>{error && <Alert severity="error">{error}</Alert>}</p> + </Grid> + <Grid item xs={1}> + {!active && ( + <Button + variant="contained" + onClick={async () => { + setShow("nothing"); + onChange(""); + }} + color="error" + > + <i18n.Translate>Clear</i18n.Translate> + </Button> + )} + </Grid> + <Grid item xs={1}> + {value && ( + <Button + disabled={!!error} + variant="contained" + color="success" + onClick={async () => onDetected(value)} + > + <i18n.Translate>Open</i18n.Translate> + </Button> + )} + </Grid> + <Grid item xs={1}> + <InputFile onChange={onFileRead}>Read QR from file</InputFile> + </Grid> + <Grid item xs={1}> + <p> + <Button variant="contained" onClick={startVideo}> + Use Camera + </Button> + </p> + </Grid> + </Grid> + </section> + <div> + <video + ref={videoRef} + style={{ display: show === "video" ? "unset" : "none" }} + playsInline={true} + /> + <canvas + id="este" + class={QrCanvas} + ref={canvasRef} + style={{ display: show === "canvas" ? "unset " : "none" }} + /> + </div> </Container> ); } - -async function scanImage( - imageRef: Ref<HTMLImageElement>, - image: string, -): Promise<void> { - const imageEl = new Image(); - imageEl.src = image; - imageEl.width = 200; - imageRef.current!.appendChild(imageEl); - QrScanner.scanImage(image, { - alsoTryWithoutScanRegion: true, - }) - .then((result) => console.log(result)) - .catch((error) => console.log(error || "No QR code found.")); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd352a8c..99c71d823 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -574,6 +574,7 @@ importers: date-fns: ^2.29.2 esbuild: ^0.15.13 history: 4.10.1 + jsqr: ^1.4.0 mocha: ^9.2.0 nyc: ^15.1.0 polished: ^4.1.4 @@ -581,7 +582,7 @@ importers: preact-cli: ^3.3.5 preact-render-to-string: ^5.1.19 preact-router: 3.2.1 - qr-scanner: ^1.4.1 + qr-scanner: 1.4.2 qrcode-generator: ^1.4.4 rimraf: ^3.0.2 tslib: ^2.4.0 @@ -591,9 +592,10 @@ importers: '@gnu-taler/taler-wallet-core': link:../taler-wallet-core date-fns: 2.29.3 history: 4.10.1 + jsqr: 1.4.0 preact: 10.11.3 preact-router: 3.2.1_preact@10.11.3 - qr-scanner: 1.4.1 + qr-scanner: 1.4.2 qrcode-generator: 1.4.4 tslib: 2.4.0 devDependencies: @@ -10683,6 +10685,10 @@ packages: verror: 1.10.0 dev: true + /jsqr/1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + dev: false + /jssha/3.3.0: resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==} dev: true @@ -13210,8 +13216,8 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} dev: true - /qr-scanner/1.4.1: - resolution: {integrity: sha512-xiR90NONHTfTwaFgW/ihlqjGMIZg6ExHDOvGQRba1TvV+WVw7GoDArIOt21e+RO+9WiO4AJJq+mwc5f4BnGH3w==} + /qr-scanner/1.4.2: + resolution: {integrity: sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==} dependencies: '@types/offscreencanvas': 2019.7.0 dev: false |