Skip to content

Commit a204b64

Browse files
fix(frontend): Improve QR scanning reliability for dark borderless codes (#12162)
# Note This PR is a fork of dfinity/gix-components#759. Awaiting that review, we simply copy the components in our repo and add it to the next release, since it is a time-sensitive issue. # Motivation The QR code reader was using `html5-qrcode`, which has a known issue with inverted (dark-themed, borderless) QR codes on mobile — they simply cannot be scanned. The library is also unmaintained. Known upstream issues: - mebjas/html5-qrcode#766 - mebjas/html5-qrcode#468 - mebjas/html5-qrcode#94 Since the root cause is a limitation of `html5-qrcode` itself (no support for inverted QR codes), and the library is no longer maintained, this PR replaces it with [`barcode-detector`](https://www.npmjs.com/package/barcode-detector) — a [Barcode Detection API](https://developer.mozilla.org/en-US/docs/Web/API/BarcodeDetector) polyfill powered by ZXing C++ WebAssembly under the hood. The underlying engine natively supports inverted code detection (`tryInvert` is enabled by default). We tested it manually on a mobile device and dark borderless QR codes are now successfully scanned. Thanks to @sea-snake for the tip! # Changes (copied from gix-components's `QRCodeReader`) - Removed the `html5-qrcode` dependency entirely in component `QRCodeReader`. - In component `QRCodeReader`: - Camera access is now handled directly via the browser `navigator.mediaDevices.getUserMedia` API, requesting the rear-facing camera (`facingMode: "environment"`) at up to 1920×1080 resolution. - The video feed is rendered in a native `<video>` element. - QR decoding uses the standard `BarcodeDetector` API (via `barcode-detector/ponyfill`). A detector instance is created with `formats: ["qr_code"]` and `detector.detect(videoElement)` is called at ~10 fps via `setInterval(scan, 100)`. The polyfill accepts the `<video>` element directly — no intermediate `<canvas>` needed. - A frame-processing guard (`isProcessingFrame`) prevents overlapping decode calls. - Cleanup on destroy properly clears the scan interval and stops all media tracks. - The scan overlay is now implemented with a `.scan-overlay` container with a `.scan-region` box using `box-shadow` to mask the surrounding area and a border styled with the primary color. This replaces the old `:global(#qr-shaded-region)` overrides that targeted `html5-qrcode`'s internal DOM. - Updated the library reference in the QR code reader docs page from `jsQR` to `barcode-detector`. - Added `barcode-detector@^3.1.1` to dependencies and removed `html5-qrcode`. # Tests Manual testing on mobile: dark borderless QR codes are now successfully scanned (this was the main validation, as the core issue only reproduced on real mobile devices). <img width="1071" height="1428" alt="IMG_5315" src="https://github.com/user-attachments/assets/f25cbe60-3d92-472c-917e-ffdfb5f1b343" /> <img width="1179" height="2556" alt="IMG_5314" src="https://github.com/user-attachments/assets/6fd3d30d-2080-4464-8263-80ce3d544698" />
1 parent 01ac870 commit a204b64

File tree

6 files changed

+231
-4
lines changed

6 files changed

+231
-4
lines changed

package-lock.json

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"@solana/kit": "^6.0.1",
9090
"@velora-dex/sdk": "^9.3.3",
9191
"alchemy-sdk": "^3.6.5",
92+
"barcode-detector": "^3.1.1",
9293
"bech32": "^1.1.4",
9394
"bitcoinjs-lib": "^6.1.7",
9495
"browser-image-compression": "^2.0.2",

src/frontend/src/lib/components/qr/QrCodeScanner.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
2-
import { QRCodeReader } from '@dfinity/gix-components';
32
import { onMount } from 'svelte';
3+
import QrCodeReader from '$lib/components/ui/QrCodeReader.svelte';
44
import { ADDRESS_BOOK_QR_CODE_SCAN } from '$lib/constants/test-ids.constants';
55
import type { QrStatus } from '$lib/types/qr-code';
66
@@ -46,7 +46,7 @@
4646
class="stretch qr-code-wrapper h-full w-full md:min-h-[300px]"
4747
data-tid={ADDRESS_BOOK_QR_CODE_SCAN}
4848
>
49-
<QRCodeReader on:nnsCancel={onCancel} on:nnsQRCode={onQRCode} />
49+
<QrCodeReader on:nnsCancel={onCancel} on:nnsQRCode={onQRCode} />
5050
</div>
5151

5252
<style lang="scss">
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<script lang="ts">
2+
import { isNullish, nonNullish } from '@dfinity/utils';
3+
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
4+
import { isDesktop } from '$lib/utils/device.utils';
5+
import { nextElementId } from '$lib/utils/html.utils';
6+
7+
const id = nextElementId('qrcode-reader-');
8+
9+
const dispatch = createEventDispatcher();
10+
11+
let videoElement: HTMLVideoElement | undefined;
12+
let stream: MediaStream | undefined;
13+
let scanInterval: NodeJS.Timeout | undefined;
14+
let isDestroyed = false;
15+
let isProcessingFrame = false;
16+
17+
onMount(async () => {
18+
try {
19+
stream = await navigator.mediaDevices.getUserMedia({
20+
video: {
21+
facingMode: 'environment',
22+
width: { ideal: 1920 },
23+
height: { ideal: 1080 }
24+
},
25+
audio: false
26+
});
27+
28+
if (isDestroyed) {
29+
stopStream();
30+
31+
return;
32+
}
33+
34+
if (nonNullish(videoElement)) {
35+
videoElement.srcObject = stream;
36+
37+
await videoElement.play();
38+
39+
await startScanning();
40+
}
41+
} catch (err: unknown) {
42+
dispatch('nnsQRCodeError', err);
43+
}
44+
});
45+
46+
const startScanning = async () => {
47+
try {
48+
const { BarcodeDetector } = await import('barcode-detector/ponyfill');
49+
50+
if (isDestroyed) {
51+
return;
52+
}
53+
54+
const detector = new BarcodeDetector({
55+
formats: ['qr_code']
56+
});
57+
58+
const scan = async () => {
59+
if (
60+
isProcessingFrame ||
61+
isDestroyed ||
62+
isNullish(videoElement) ||
63+
videoElement.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
64+
) {
65+
return;
66+
}
67+
68+
isProcessingFrame = true;
69+
70+
try {
71+
const results = await detector.detect(videoElement);
72+
73+
const [qrResult] = results;
74+
75+
if (nonNullish(qrResult)) {
76+
dispatch('nnsQRCode', qrResult.rawValue);
77+
}
78+
} catch {
79+
// Decoding failed on this frame — expected when no QR code is visible
80+
} finally {
81+
isProcessingFrame = false;
82+
}
83+
};
84+
85+
scanInterval = setInterval(scan, 100);
86+
} catch (err: unknown) {
87+
dispatch('nnsQRCodeError', err);
88+
}
89+
};
90+
91+
const stopStream = () => {
92+
if (nonNullish(stream)) {
93+
stream.getTracks().forEach((track) => track.stop());
94+
95+
stream = undefined;
96+
}
97+
};
98+
99+
onDestroy(() => {
100+
isDestroyed = true;
101+
102+
if (nonNullish(scanInterval)) {
103+
clearInterval(scanInterval);
104+
105+
scanInterval = undefined;
106+
}
107+
108+
stopStream();
109+
});
110+
111+
// We optimistically assume that if the QR code reader is used on desktop, it has most probably only a single "user" facing camera and that we can invert the displayed video
112+
const mirror = isDesktop();
113+
</script>
114+
115+
<article {id} class="reader" class:mirror>
116+
<video bind:this={videoElement} autoplay muted playsinline></video>
117+
118+
<div class="scan-overlay">
119+
<div class="scan-region"></div>
120+
</div>
121+
</article>
122+
123+
<style lang="scss">
124+
.reader {
125+
position: relative;
126+
width: 100%;
127+
height: 100%;
128+
129+
border-radius: var(--border-radius);
130+
overflow: hidden;
131+
132+
&.mirror {
133+
transform: scaleX(-1);
134+
}
135+
}
136+
137+
video {
138+
display: block;
139+
width: 100%;
140+
height: 100%;
141+
object-fit: cover;
142+
}
143+
144+
.scan-overlay {
145+
position: absolute;
146+
inset: 0;
147+
container-type: size;
148+
display: flex;
149+
align-items: center;
150+
justify-content: center;
151+
pointer-events: none;
152+
}
153+
154+
.scan-region {
155+
width: min(90cqw, 90cqh);
156+
aspect-ratio: 1;
157+
box-shadow: 0 0 0 9999px white;
158+
border: 2px solid rgba(var(--primary-rgb), 0.4);
159+
}
160+
</style>

src/frontend/src/lib/components/wallet-connect/WalletConnectForm.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<script lang="ts">
2-
import { QRCodeReader } from '@dfinity/gix-components';
32
import Button from '$lib/components/ui/Button.svelte';
43
import ButtonGroup from '$lib/components/ui/ButtonGroup.svelte';
54
import ContentWithToolbar from '$lib/components/ui/ContentWithToolbar.svelte';
65
import InputText from '$lib/components/ui/InputText.svelte';
6+
import QrCodeReader from '$lib/components/ui/QrCodeReader.svelte';
77
import {
88
TRACK_COUNT_WALLET_CONNECT,
99
TRACK_COUNT_WALLET_CONNECT_QR_CODE
@@ -71,7 +71,7 @@
7171
<ContentWithToolbar>
7272
<div class="qr-code rounded-lg">
7373
{#if renderQRCodeReader}
74-
<QRCodeReader on:nnsQRCode={onQRCodeSuccess} on:nnsQRCodeError={error} />
74+
<QrCodeReader on:nnsQRCode={onQRCodeSuccess} on:nnsQRCodeError={error} />
7575
{/if}
7676

7777
{#if !renderQRCodeReader}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
let elementsCounters: Record<string, number> = {};
2+
3+
export const nextElementId = (prefix: string): string => {
4+
elementsCounters = {
5+
...elementsCounters,
6+
[prefix]: (elementsCounters[prefix] ?? 0) + 1
7+
};
8+
9+
return `${prefix}${elementsCounters[prefix]}`;
10+
};

0 commit comments

Comments
 (0)