diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/QrReader.tsx')
-rw-r--r-- | packages/taler-wallet-webextension/src/wallet/QrReader.tsx | 392 |
1 files changed, 392 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx new file mode 100644 index 000000000..a01ea6967 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -0,0 +1,392 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + assertUnreachable, + parseTalerUri, + TalerUri, + TalerUriAction, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { css } from "@linaria/core"; +import { styled } from "@linaria/react"; +import jsQR, * as pr from "jsqr"; +import { h, VNode } from "preact"; +import { useRef, useState } from "preact/hooks"; +import { EnabledBySettings } from "../components/EnabledBySettings.js"; +import { Alert } from "../mui/Alert.js"; +import { Button } from "../mui/Button.js"; +import { Grid } from "../mui/Grid.js"; +import { InputFile } from "../mui/InputFile.js"; +import { TextField } from "../mui/TextField.js"; + +const QrCanvas = css` + width: 80%; + margin-left: auto; + margin-right: auto; + padding: 8px; + background-color: black; +`; + +const LINE_COLOR = "#FF3B58"; + +const Container = styled.div` + display: flex; + flex-direction: column; + & > * { + margin-bottom: 20px; + } +`; + +export interface Props { + onDetected: (url: TalerUri) => 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.default(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", () => { + 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 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 onChangeDetect(str: string) { + if (str) { + const uri = parseTalerUri(str); + if (!uri) { + setError( + i18n.str`URI is not valid. Taler URI should start with "taler://"`, + ); + } else { + onDetected(uri); + setError(undefined); + } + } else { + setError(undefined); + } + setValue(str); + } + + function onChange(str: string) { + if (str) { + if (!parseTalerUri(str)) { + setError( + i18n.str`URI is not valid. Taler URI should start with "taler://"`, + ); + } else { + setError(undefined); + } + } else { + setError(undefined); + } + setValue(str); + } + + 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) { + onChangeDetect(code); + setShow("canvas"); + } + stream.getTracks().forEach((e) => { + e.stop(); + }); + } catch (error) { + setError(i18n.str`something unexpected happen: ${error}`); + } + } + + async function onFileRead(fileContent: string) { + if (!canvasRef.current) { + return; + } + setShow("nothing"); + setError(undefined); + try { + const code = await createCanvasFromFile(fileContent, canvasRef.current); + if (code) { + onChangeDetect(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 uri = parseTalerUri(value); + + return ( + <Container> + <section> + <h1> + <i18n.Translate> + Scan a QR code or enter taler:// URI below + </i18n.Translate> + </h1> + <div style={{ justifyContent: "space-between", display: "flex" }}> + <div style={{ width: "75%" }}> + <TextField + label="Taler URI" + variant="filled" + fullWidth + value={value} + onChange={onChange} + /> + </div> + {uri && ( + <Button + disabled={!!error} + variant="contained" + color="success" + onClick={async () => { + if (uri) onDetected(uri); + }} + > + {(function (talerUri: TalerUri): VNode { + switch (talerUri.type) { + case TalerUriAction.Pay: + return <i18n.Translate>Pay invoice</i18n.Translate>; + case TalerUriAction.Withdraw: + return ( + <i18n.Translate>Withdrawal from bank</i18n.Translate> + ); + case TalerUriAction.Refund: + return <i18n.Translate>Claim refund</i18n.Translate>; + case TalerUriAction.PayPull: + return <i18n.Translate>Pay invoice</i18n.Translate>; + case TalerUriAction.PayPush: + return <i18n.Translate>Accept payment</i18n.Translate>; + case TalerUriAction.PayTemplate: + return <i18n.Translate>Complete order</i18n.Translate>; + case TalerUriAction.Restore: + return <i18n.Translate>Restore wallet</i18n.Translate>; + case TalerUriAction.DevExperiment: + return <i18n.Translate>Enable experiment</i18n.Translate>; + case TalerUriAction.WithdrawExchange: + return ( + <i18n.Translate>Withdraw from exchange</i18n.Translate> + ); + case TalerUriAction.AddExchange: + return <i18n.Translate>Add exchange</i18n.Translate>; + default: { + assertUnreachable(talerUri); + } + } + })(uri)} + </Button> + )} + </div> + <Grid container justifyContent="space-around" columns={2}> + <Grid item xs={2}> + <p>{error && <Alert severity="error">{error}</Alert>}</p> + </Grid> + <Grid item xs={2}> + <p> + <Button variant="contained" onClick={startVideo}> + Use Camera + </Button> + </p> + </Grid> + <EnabledBySettings name="advancedMode"> + <Grid item xs={2}> + <InputFile onChange={onFileRead}>Read QR from file</InputFile> + </Grid> + </EnabledBySettings> + </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> + ); +} |