summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/wallet/QrReader.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/QrReader.tsx')
-rw-r--r--packages/taler-wallet-webextension/src/wallet/QrReader.tsx392
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>
+ );
+}