fix #7535: fix qr implementation
This commit is contained in:
parent
6f24b5a05e
commit
9d9a88af01
@ -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"
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
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 { i18n } = useTranslationContext();
|
||||
type XY = { x: number; y: number };
|
||||
|
||||
function start(): void {
|
||||
qrScanner.current!.start();
|
||||
onChange("");
|
||||
setActive(true);
|
||||
}
|
||||
function stop(): void {
|
||||
qrScanner.current!.stop();
|
||||
setActive(false);
|
||||
}
|
||||
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 check(v: string) {
|
||||
return (
|
||||
v.startsWith("taler://") && classifyTalerUri(v) !== TalerUriType.Unknown
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) {
|
||||
console.log("vide was not ready");
|
||||
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);
|
||||
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>
|
||||
)}
|
||||
</Container>
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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."));
|
||||
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 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 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);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileRead(fileContent: string) {
|
||||
if (!canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user