Merge pull request #80 from multica-ai/feat/web-qr-scanner

feat(ui): add QR code scanner to ConnectPrompt
This commit is contained in:
Naiyuan Qing 2026-02-04 15:44:02 +08:00 committed by GitHub
commit 096b284cef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 363 additions and 40 deletions

2
apps/web/.gitignore vendored
View file

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
certificates

View file

@ -22,6 +22,7 @@
"clsx": "^2.1.1",
"linkify-it": "^5.0.0",
"next-themes": "^0.4.6",
"qr-scanner": "^1.4.2",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { toast } from "@multica/ui/components/ui/sonner";
@ -9,54 +9,157 @@ import {
parseConnectionCode,
saveConnection,
} from "@multica/store";
import { HugeiconsIcon } from "@hugeicons/react";
import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons";
const LazyQrScannerView = lazy(() =>
import("@multica/ui/components/qr-scanner-view").then((m) => ({
default: m.QrScannerView,
})),
);
type Mode = "scan" | "paste";
export function ConnectPrompt() {
const gwState = useConnectionStore((s) => s.connectionState)
const [codeInput, setCodeInput] = useState("")
const gwState = useConnectionStore((s) => s.connectionState);
const [codeInput, setCodeInput] = useState("");
const [mode, setMode] = useState<Mode>("paste"); // SSR-safe default
const [canScan, setCanScan] = useState(false);
const scannedRef = useRef(false);
const handleConnect = useCallback(() => {
const trimmed = codeInput.trim()
if (!trimmed) return
try {
const info = parseConnectionCode(trimmed)
saveConnection(info)
useConnectionStore.getState().connect(info)
setCodeInput("")
} catch (e) {
toast.error((e as Error).message)
// Detect mobile + camera capability, auto-switch to scan mode
useEffect(() => {
const isTouchDevice =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
const isNarrow = window.innerWidth < 768;
const hasGetUserMedia = !!navigator.mediaDevices?.getUserMedia;
if (hasGetUserMedia) {
setCanScan(true);
if (isTouchDevice && isNarrow) {
setMode("scan");
}
}
}, [codeInput])
}, []);
// Handle paste-mode connect
const handleConnect = useCallback(() => {
const trimmed = codeInput.trim();
if (!trimmed) return;
try {
const info = parseConnectionCode(trimmed);
saveConnection(info);
useConnectionStore.getState().connect(info);
setCodeInput("");
} catch (e) {
toast.error((e as Error).message);
}
}, [codeInput]);
// Handle QR scan result — auto-connect, no button needed
const handleQrScan = useCallback((data: string) => {
// Prevent duplicate connects from rapid successive scans
if (scannedRef.current) return;
scannedRef.current = true;
try {
const info = parseConnectionCode(data);
saveConnection(info);
useConnectionStore.getState().connect(info);
} catch (e) {
toast.error((e as Error).message);
// Allow re-scan on error (invalid/expired code)
scannedRef.current = false;
}
}, []);
const handleScanError = useCallback((msg: string) => {
toast.error(msg);
setMode("paste");
}, []);
const isConnecting = gwState === "connecting" || gwState === "connected";
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="text-center space-y-1">
<p className="text-sm text-muted-foreground">Paste a connection code to start</p>
{(gwState === "connecting" || gwState === "connected") && (
<p className="text-xs text-muted-foreground/60 animate-pulse">Connecting...</p>
<p className="text-sm text-muted-foreground">
{mode === "scan"
? "Scan QR code to connect"
: "Paste a connection code to start"}
</p>
{isConnecting && (
<p className="text-xs text-muted-foreground/60 animate-pulse">
Connecting...
</p>
)}
</div>
{/* Mode toggle — only show if camera is available */}
{canScan && (
<div className="flex gap-1 bg-muted rounded-lg p-1">
<Button
variant={mode === "scan" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => {
scannedRef.current = false;
setMode("scan");
}}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
Scan
</Button>
<Button
variant={mode === "paste" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("paste")}
>
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
Paste
</Button>
</div>
)}
{/* Content */}
<div className="w-full max-w-sm space-y-3">
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
placeholder="Paste connection code here..."
className="text-xs font-mono min-h-[100px] resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleConnect()
{mode === "scan" ? (
<Suspense
fallback={
<div className="h-[280px] animate-pulse bg-muted rounded-xl" />
}
}}
/>
<Button
size="sm"
onClick={handleConnect}
disabled={!codeInput.trim() || gwState === "connecting"}
className="w-full text-xs"
>
Connect
</Button>
>
<LazyQrScannerView
onScan={handleQrScan}
onError={handleScanError}
/>
</Suspense>
) : (
<>
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
placeholder="Paste connection code here..."
className="text-xs font-mono min-h-[100px] resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleConnect();
}
}}
/>
<Button
size="sm"
onClick={handleConnect}
disabled={!codeInput.trim() || gwState === "connecting"}
className="w-full text-xs"
>
Connect
</Button>
</>
)}
</div>
</div>
)
);
}

View file

@ -0,0 +1,82 @@
"use client"
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
interface QrScannerViewProps {
onScan: (data: string) => void
onError?: (error: string) => void
}
/**
* Camera viewfinder for QR code scanning.
*
* Renders a live camera feed with a decorative scan frame overlay.
* Uses getUserMedia via the qr-scanner library (WebWorker-based decoding).
* iOS requires playsinline + muted + autoplay on the <video> element.
*/
export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
const { videoRef, isScanning, error, hasCamera } = useQrScanner({
onScan,
onError,
enabled: true,
})
if (!hasCamera) {
return (
<div className="flex items-center justify-center h-[280px] rounded-xl bg-muted">
<p className="text-sm text-muted-foreground">No camera available</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[280px] rounded-xl bg-muted gap-2">
<p className="text-sm text-muted-foreground">Camera access denied</p>
<p className="text-xs text-muted-foreground/60">
Switch to paste mode below
</p>
</div>
)
}
return (
<div className="relative w-full max-w-[280px] mx-auto">
{/* Camera feed */}
<div className="relative aspect-square rounded-xl overflow-hidden bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 w-full h-full object-cover"
/>
{/* Scan frame overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-3/4 h-3/4">
{/* Corner accents */}
<div className="absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 border-white/70 rounded-tl-md" />
<div className="absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 border-white/70 rounded-tr-md" />
<div className="absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 border-white/70 rounded-bl-md" />
<div className="absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 border-white/70 rounded-br-md" />
</div>
</div>
{/* Loading state */}
{!isScanning && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<p className="text-xs text-white/80 animate-pulse">
Starting camera...
</p>
</div>
)}
</div>
{/* Hint text */}
<p className="text-xs text-muted-foreground text-center mt-3">
Point camera at QR code on desktop
</p>
</div>
)
}

View file

@ -0,0 +1,118 @@
"use client"
import { useRef, useState, useEffect, useCallback } from "react"
import type QrScannerLib from "qr-scanner"
export interface UseQrScannerOptions {
onScan: (data: string) => void
onError?: (error: string) => void
enabled?: boolean
}
export interface UseQrScannerResult {
videoRef: React.RefObject<HTMLVideoElement | null>
isScanning: boolean
error: string | null
hasCamera: boolean
}
/**
* Hook wrapping qr-scanner lifecycle.
*
* - Dynamically imports qr-scanner (keeps it out of SSR bundles)
* - Creates/destroys scanner instance based on `enabled`
* - Releases camera stream on cleanup
*/
export function useQrScanner({
onScan,
onError,
enabled = true,
}: UseQrScannerOptions): UseQrScannerResult {
const videoRef = useRef<HTMLVideoElement | null>(null)
const scannerRef = useRef<QrScannerLib | null>(null)
const [isScanning, setIsScanning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasCamera, setHasCamera] = useState(true)
// Stable callback refs to avoid re-creating scanner on every render
const onScanRef = useRef(onScan)
onScanRef.current = onScan
const onErrorRef = useRef(onError)
onErrorRef.current = onError
// Check camera availability once
useEffect(() => {
let cancelled = false
import("qr-scanner").then((mod) => {
const QrScanner = mod.default
QrScanner.hasCamera().then((has) => {
if (!cancelled) setHasCamera(has)
})
})
return () => { cancelled = true }
}, [])
// Start/stop scanner based on `enabled` and video element
useEffect(() => {
if (!enabled || !videoRef.current || !hasCamera) return
let destroyed = false
const video = videoRef.current
import("qr-scanner").then((mod) => {
if (destroyed) return
const QrScanner = mod.default
const scanner = new QrScanner(
video,
(result) => {
console.log("[QrScanner] scanned:", result.data)
onScanRef.current(result.data)
},
{
preferredCamera: "environment",
maxScansPerSecond: 5,
returnDetailedScanResult: true,
highlightScanRegion: false,
highlightCodeOutline: false,
onDecodeError: (err) => {
// "No QR code found" fires every frame — ignore it
if (typeof err === "string" && err.includes("No QR code found")) return
console.warn("[QrScanner] decode error:", err)
},
},
)
scannerRef.current = scanner
scanner
.start()
.then(() => {
if (!destroyed) {
console.log("[QrScanner] started successfully")
setIsScanning(true)
setError(null)
}
})
.catch((err: Error) => {
if (destroyed) return
const msg = err.message || "Camera access failed"
setError(msg)
setIsScanning(false)
onErrorRef.current?.(msg)
})
})
return () => {
destroyed = true
if (scannerRef.current) {
scannerRef.current.stop()
scannerRef.current.destroy()
scannerRef.current = null
}
setIsScanning(false)
}
}, [enabled, hasCamera])
return { videoRef, isScanning, error, hasCamera }
}

23
pnpm-lock.yaml generated
View file

@ -482,6 +482,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
qr-scanner:
specifier: ^1.4.2
version: 1.4.2
react:
specifier: 'catalog:'
version: 19.2.3
@ -3315,6 +3318,9 @@ packages:
'@types/node@25.0.10':
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
'@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/plist@3.0.5':
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
@ -5464,11 +5470,13 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.0:
@ -5477,7 +5485,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
@ -7354,6 +7362,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qr-scanner@1.4.2:
resolution: {integrity: sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==}
qrcode-terminal@0.11.0:
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
hasBin: true
@ -12506,6 +12517,8 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@types/offscreencanvas@2019.7.3': {}
'@types/plist@3.0.5':
dependencies:
'@types/node': 25.0.10
@ -14363,7 +14376,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
@ -14394,7 +14407,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -17470,6 +17483,10 @@ snapshots:
punycode@2.3.1: {}
qr-scanner@1.4.2:
dependencies:
'@types/offscreencanvas': 2019.7.3
qrcode-terminal@0.11.0: {}
qrcode.react@4.2.0(react@19.2.3):