diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/taler-wallet-webextension/package.json | 2 | ||||
| -rw-r--r-- | packages/taler-wallet-webextension/src/wallet/QrReader.tsx | 396 | 
2 files changed, 300 insertions, 98 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.")); -} | 
