aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/taler-wallet-webextension/package.json2
-rw-r--r--packages/taler-wallet-webextension/src/wallet/QrReader.tsx396
-rw-r--r--pnpm-lock.yaml14
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