Agent Skills: QR Code Scanning

Use when implementing QR code scanning - provides complete patterns for camera-based scanning, classifying scanned content (Bitcoin addresses, Lightning invoices, npubs, Cashu tokens), handling camera permissions, and paste from clipboard functionality

UncategorizedID: PerceptLabs/nostril/qr-code-scanner

Install this agent skill to your local

pnpm dlx add-skill https://github.com/PerceptLabs/nostril/tree/HEAD/skills/qr-code-scanner

Skill Files

Browse the full folder contents for qr-code-scanner.

Download Skill

Loading file tree…

skills/qr-code-scanner/SKILL.md

Skill Metadata

Name
qr-code-scanner
Description
Use when implementing QR code scanning - provides complete patterns for camera-based scanning, classifying scanned content (Bitcoin addresses, Lightning invoices, npubs, Cashu tokens), handling camera permissions, and paste from clipboard functionality

QR Code Scanning

Overview

Complete implementation guide for QR code scanning with camera-based detection and content classification. Supports Bitcoin addresses, Lightning invoices, Lightning addresses, Nostr npubs, and Cashu tokens.

Core Capabilities:

  • Camera-based QR code scanning
  • Automatic content classification
  • Camera permission handling
  • Paste from clipboard functionality
  • Error recovery and fallbacks

Prerequisites

IMPORTANT: Before adding dependencies, review your project's package.json to check if any of these packages already exist. If they do, verify the versions are compatible with the requirements below. Only add packages that are missing or need version updates.

Required packages:

{
  "html5-qrcode": "^2.3.8"
}

Implementation Checklist

  • [ ] Install QR scanner package (html5-qrcode)
  • [ ] Implement content classification logic
  • [ ] Implement QR code scanning hook
  • [ ] Handle camera permissions
  • [ ] Add paste from clipboard functionality
  • [ ] Add error handling and fallbacks
  • [ ] Create QR scanner component

Part 1: Content Classification

CRITICAL: Classify scanned content before processing to route to correct handlers.

// hooks/useQRCodeScanner.ts
export type QRScanResult = 
  | { type: 'lightning_invoice'; value: string }
  | { type: 'lightning_address'; value: string }
  | { type: 'cashu_token'; value: string }
  | { type: 'bitcoin_address'; value: string }
  | { type: 'npub'; value: string }
  | { type: 'unknown'; value: string };

export function classifyQRCode(decodedText: string): QRScanResult {
  // Lightning invoice: lnbc... or lightning:lnbc...
  const lightningInvoiceMatch = decodedText.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i);
  if (lightningInvoiceMatch) {
    return { type: 'lightning_invoice', value: lightningInvoiceMatch[1] };
  }

  // Lightning address: name@domain.tld or lightning:name@domain.tld
  const lightningAddressMatch = decodedText.match(/^(?:lightning:)?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/);
  if (lightningAddressMatch) {
    return { type: 'lightning_address', value: lightningAddressMatch[1] };
  }

  // Cashu token: cashu...
  if (/^cashu[A-Za-z0-9+/=_-]+$/.test(decodedText)) {
    return { type: 'cashu_token', value: decodedText };
  }

  // Bitcoin address: bc1... or legacy (1..., 3...)
  if (/^(bc1|3|1)[a-zA-Z0-9]{25,62}$/.test(decodedText)) {
    return { type: 'bitcoin_address', value: decodedText };
  }

  // Nostr npub: npub1... or nostr:npub1...
  const npubMatch = decodedText.match(/^(?:nostr:)?(npub1[a-z0-9]{58})$/);
  if (npubMatch) {
    return { type: 'npub', value: npubMatch[1] };
  }

  return { type: 'unknown', value: decodedText };
}

Why classify first?

  • Routes to correct handler automatically
  • Validates format before processing
  • Provides better error messages
  • Prevents processing invalid data

Part 2: Camera Scanning Hook

// hooks/useQRCodeScanner.ts
import { useState, useCallback, useRef } from 'react';
import { Html5Qrcode } from 'html5-qrcode';
// optional import { useToast } from '@/hooks/useToast';
import type { QRScanResult } from './useQRCodeScanner';

export function useQRCodeScanner() {
  const [isScanning, setIsScanning] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const scannerRef = useRef<Html5Qrcode | null>(null);
  const isStartingRef = useRef(false);
  const shouldCancelRef = useRef(false);
  // Optional: User feedback notifications
  // Option 1: Console logging
  // const logMessage = (message: string) => console.log(message);
  // Option 2: Toast notifications (if useToast hook is available)
  // const { toast } = useToast();
  // Option 3: No notification handler

  const startScanning = useCallback(async (
    onResult: (result: QRScanResult) => void
  ): Promise<void> => {
    try {
      isStartingRef.current = true;
      shouldCancelRef.current = false;
      setError(null);
      
      // Find available cameras
      const devices = await Html5Qrcode.getCameras();
      if (devices.length === 0) {
        throw new Error('No cameras found');
      }

      // Check if cancelled during camera detection
      if (shouldCancelRef.current) {
        isStartingRef.current = false;
        return;
      }

      // Use rear camera by default, or first available
      const cameraId = devices.length > 1 
        ? devices.find(d => 
            d.label.toLowerCase().includes('back') || 
            d.label.toLowerCase().includes('rear')
          )?.id || devices[0].id
        : devices[0].id;

      // Initialize scanner (requires element with id="qr-reader")
      const scanner = new Html5Qrcode('qr-reader');
      scannerRef.current = scanner;

      // Start scanning
      await scanner.start(
        cameraId,
        {
          fps: 10,
          aspectRatio: 1,
          qrbox: 250
        },
        (decodedText) => {
          // Classify and handle result
          const result = classifyQRCode(decodedText);
          onResult(result);
        },
        (errorMessage) => {
          // Ignore "QR code not found" errors (normal during scanning)
          if (!errorMessage.includes('qr')) {
            console.debug('QR scan error:', errorMessage);
          }
        }
      );

      isStartingRef.current = false;
      setIsScanning(true);
      
    } catch (err) {
      isStartingRef.current = false;
      let errorMessage = 'Failed to start camera';
      
      if (err instanceof Error) {
        errorMessage = err.message;
      }
      
      // Provide helpful error messages
      if (errorMessage.includes('NotAllowedError') || errorMessage.includes('Permission denied')) {
        errorMessage = 'Camera permission denied. Please allow camera access.';
      } else if (errorMessage.includes('NotFoundError') || errorMessage.includes('No device')) {
        errorMessage = 'No camera found. Please connect a camera.';
      }
      
      setError(errorMessage);
      setIsScanning(false);
      // Optional: User feedback - choose one:
      // Option 1: Console logging
      // console.error('Camera Error:', errorMessage);
      // Option 2: Toast notification (if toast is available)
      // toast({ variant: "destructive", title: "Camera Error", description: errorMessage });
      // Option 3: No notification (silent failure)
    }
  }, []);

  const stopScanning = useCallback(async (): Promise<void> => {
    try {
      // Handle cancellation during startup
      if (isStartingRef.current) {
        shouldCancelRef.current = true;
        // Wait for startup to complete
        let attempts = 0;
        while (isStartingRef.current && attempts < 50) {
          await new Promise(resolve => setTimeout(resolve, 100));
          attempts++;
        }
      }
      
      if (scannerRef.current) {
        const scanner = scannerRef.current;
        
        // Check scanner state before stopping
        const state = scanner.getState();
        if (state === 2) { // SCANNING
          await scanner.stop();
        } else if (state === 1) { // STARTING
          await new Promise(resolve => setTimeout(resolve, 200));
          if (scanner.getState() === 2) {
            await scanner.stop();
          }
        }
        
        await scanner.clear();
        scannerRef.current = null;
      }
      
      shouldCancelRef.current = false;
      isStartingRef.current = false;
      setIsScanning(false);
      setError(null);
    } catch (err) {
      console.error('Error stopping scanner:', err);
      scannerRef.current = null;
      setIsScanning(false);
    }
  }, []);

  return {
    isScanning,
    error,
    startScanning,
    stopScanning,
    classifyQRCode,
  };
}

Part 3: QR Scanner Component

// components/QRScanner.tsx
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
// optional import { useToast } from '@/hooks/useToast';
import type { QRScanResult } from '@/hooks/useQRCodeScanner';

interface QRScannerProps {
  isOpen: boolean;
  onClose: () => Promise<void>;
  onResult: (result: QRScanResult) => void;
  classifyQRCode: (text: string) => QRScanResult;
  startScanning: (onResult: (result: QRScanResult) => void) => Promise<void>;
  stopScanning: () => Promise<void>;
  setModalOpen?: (isOpen: boolean) => void;
  qrError: string | null;
}

export function QRScanner({
  isOpen,
  onClose,
  onResult,
  classifyQRCode,
  startScanning,
  stopScanning,
  setModalOpen,
  qrError,
}: QRScannerProps) {
  // Optional: User feedback notifications
  // Option 1: Console logging
  // const logMessage = (message: string) => console.log(message);
  // Option 2: Toast notifications (if useToast hook is available)
  // const { toast } = useToast();
  // Option 3: No notification handler

  // Handle paste from clipboard
  const handlePasteQRCode = async () => {
    try {
      const text = await navigator.clipboard.readText();
      if (!text || !text.trim()) {
        // Optional: User feedback - choose one:
        // Option 1: Console logging
        // console.error('Clipboard Empty: No text found in clipboard');
        // Option 2: Toast notification (if toast is available)
        // toast({ variant: "destructive", title: "Clipboard Empty", description: "No text found in clipboard" });
        // Option 3: No notification (silent failure)
        return;
      }

      const result = classifyQRCode(text.trim());
      onResult(result);
      await stopScanning();
      await onClose();
    } catch (err) {
      console.error('Error pasting from clipboard:', err);
      // Optional: User feedback - choose one:
      // Option 1: Console logging
      // console.error('Paste Failed: Could not read from clipboard');
      // Option 2: Toast notification (if toast is available)
      // toast({ variant: "destructive", title: "Paste Failed", description: "Could not read from clipboard" });
      // Option 3: No notification (silent failure)
    }
  };

  // Start/stop scanning based on isOpen
  useEffect(() => {
    if (isOpen) {
      setModalOpen?.(true);
      startScanning(onResult);
    } else {
      setModalOpen?.(false);
      stopScanning();
    }
  }, [isOpen, startScanning, stopScanning, setModalOpen, onResult]);

  return (
    <Dialog open={isOpen} onOpenChange={async (open) => {
      if (!open) {
        await onClose();
      }
    }}>
      <DialogContent className="max-w-[min(90vw,500px)] w-full p-4 sm:p-6">
        <DialogHeader>
          <DialogTitle>Scan QR Code</DialogTitle>
          <VisuallyHidden.Root asChild>
            <DialogDescription>
              Position the QR code within the camera view to scan
            </DialogDescription>
          </VisuallyHidden.Root>
        </DialogHeader>

        <div className="relative w-full" style={{ aspectRatio: '1 / 1' }}>
          {/* Scanner requires element with id="qr-reader" */}
          <div
            id="qr-reader"
            className="w-full h-full rounded-xl overflow-hidden bg-muted"
          />

          {/* Error overlay */}
          {qrError && (
            <div className="absolute inset-x-4 top-4 p-3 bg-destructive text-destructive-foreground rounded-lg text-center z-50 shadow-lg">
              {qrError}
            </div>
          )}
        </div>

        {/* Paste from clipboard button */}
        <div className="mt-4 flex justify-center">
          <Button
            onClick={handlePasteQRCode}
            className="h-12 px-12 rounded-full"
            size="lg"
          >
            Paste
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

Key Features:

  • Paste from clipboard - Alternative to camera scanning
  • Error overlay - Displays camera errors above scanner
  • Proper cleanup - Stops scanner when dialog closes
  • Accessibility - Uses VisuallyHidden for screen readers
  • Responsive - Works on mobile and desktop
  • Aspect ratio - Maintains square scanner viewport

Part 4: Paste from Clipboard

CRITICAL: Always provide paste functionality as an alternative to camera scanning. Users may have QR codes in text format or camera may be unavailable.

Paste Implementation

// In QRScanner component
const handlePasteQRCode = async () => {
  try {
    const text = await navigator.clipboard.readText();
    if (!text || !text.trim()) {
      // Optional: User feedback - choose one:
      // Option 1: Console logging
      // console.error('Clipboard Empty: No text found in clipboard');
      // Option 2: Toast notification (if toast is available)
      // toast({ variant: "destructive", title: "Clipboard Empty", description: "No text found in clipboard" });
      // Option 3: No notification (silent failure)
      return;
    }

    // Classify and process pasted content
    const result = classifyQRCode(text.trim());
    onResult(result);
    await stopScanning();
    await onClose();
  } catch (err) {
    // Optional: User feedback - choose one:
    // Option 1: Console logging
    // console.error('Paste Failed: Could not read from clipboard');
    // Option 2: Toast notification (if toast is available)
    // toast({ variant: "destructive", title: "Paste Failed", description: "Could not read from clipboard" });
    // Option 3: No notification (silent failure)
  }
};

Why paste is essential:

  • Camera may be unavailable or denied
  • Users may have QR code as text
  • Faster for users who already have text copied
  • Better accessibility for users who can't use camera

Part 5: Content Type Handling

Bitcoin Address Handling

// Handle scanned Bitcoin address
function handleQRResult(result: QRScanResult) {
  if (result.type === 'bitcoin_address') {
    // Validate address format
    if (isValidBitcoinAddress(result.value)) {
      setBitcoinRecipient(result.value);
      setShowSendModal(true);
    } else {
      // Optional: User feedback - choose one:
      // Option 1: Console logging
      // console.error('Invalid Address: Scanned address is not valid');
      // Option 2: Toast notification (if toast is available)
      // toast({ variant: "destructive", title: "Invalid Address", description: "Scanned address is not valid" });
      // Option 3: No notification (silent failure)
    }
  }
}

Lightning Invoice Handling

if (result.type === 'lightning_invoice') {
  // Decode invoice to get amount
  const decoded = decodeLightningInvoice(result.value);
  setLightningInvoice(result.value);
  setDecodedInvoiceAmount(decoded.amount);
  setShowPayModal(true);
}

Lightning Address Handling

if (result.type === 'lightning_address') {
  // Validate and process Lightning address
  setLightningAddress(result.value);
  setShowPayModal(true);
}

Cashu Token Handling

if (result.type === 'cashu_token') {
  // Process Cashu token
  setCashuToken(result.value);
  setShowRedeemModal(true);
}

npub Handling

if (result.type === 'npub') {
  try {
    // Convert npub to Bitcoin address if needed
    const address = npubToBitcoinAddress(result.value);
    setBitcoinRecipient(address);
    setShowSendModal(true);
  } catch {
    // Optional: User feedback - choose one:
    // Option 1: Console logging
    // console.error('Invalid npub: Could not decode npub');
    // Option 2: Toast notification (if toast is available)
    // toast({ variant: "destructive", title: "Invalid npub", description: "Could not decode npub" });
    // Option 3: No notification (silent failure)
  }
}

Unknown Content Handling

if (result.type === 'unknown') {
  // Optional: User feedback - choose one:
  // Option 1: Console logging
  // console.error('Unknown Content: Could not classify scanned QR code');
  // Option 2: Toast notification (if toast is available)
  // toast({ variant: "destructive", title: "Unknown Content", description: "Could not classify scanned QR code" });
  // Option 3: No notification (silent failure)
  
  // Or show raw content for user to decide
  setRawContent(result.value);
  setShowUnknownContentModal(true);
}

Common Pitfalls

1. ❌ Missing scanner element

Problem: Html5Qrcode requires a DOM element with id="qr-reader" to mount the scanner.

Solution: Always include the element before calling startScanning:

<div id="qr-reader" className="w-full" />

2. ❌ Not handling camera permissions

Problem: Browser blocks camera access without user permission, causing errors.

Solution: Provide clear error messages and instructions:

if (errorMessage.includes('NotAllowedError')) {
  errorMessage = 'Camera permission denied. Please allow camera access.';
}

3. ❌ Not cleaning up scanner

Problem: Scanner continues running after component unmounts, causing memory leaks.

Solution: Always stop scanner in cleanup:

useEffect(() => {
  if (isOpen) {
    startScanning(onResult);
  }
  
  return () => {
    stopScanning(); // Cleanup on unmount
  };
}, [isOpen]);

4. ❌ Not providing paste alternative

Problem: Camera may be unavailable, denied, or users may have QR code as text. Without paste, users are blocked.

Solution: Always include paste from clipboard functionality:

const handlePasteQRCode = async () => {
  const text = await navigator.clipboard.readText();
  const result = classifyQRCode(text.trim());
  onResult(result);
  await stopScanning();
  await onClose();
};

Why: Paste provides accessibility and fallback when camera fails.

5. ❌ Not classifying scanned content

Problem: Processing unknown content types causes errors.

Solution: Always classify first, then route:

const result = classifyQRCode(decodedText);
if (result.type === 'bitcoin_address') {
  // Handle Bitcoin address
} else if (result.type === 'lightning_invoice') {
  // Handle Lightning invoice
}

6. ❌ Scanner state management issues

Problem: Trying to stop scanner while it's starting causes errors.

Solution: Use refs to track state and handle cancellation:

const isStartingRef = useRef(false);
const shouldCancelRef = useRef(false);

// Check state before operations
if (isStartingRef.current) {
  shouldCancelRef.current = true;
  // Wait for startup to complete
}

7. ❌ Not handling multiple scans

Problem: Scanner may trigger multiple times for the same QR code.

Solution: Debounce or prevent duplicate scans:

const lastScannedRef = useRef<string>('');

const handleScan = (result: QRScanResult) => {
  if (lastScannedRef.current === result.value) {
    return; // Ignore duplicate scan
  }
  lastScannedRef.current = result.value;
  onResult(result);
};

Testing Strategy

Unit Tests

import { describe, it, expect } from 'vitest';
import { classifyQRCode } from '@/hooks/useQRCodeScanner';

describe('classifyQRCode', () => {
  it('classifies Bitcoin addresses', () => {
    const result = classifyQRCode('bc1p...');
    expect(result.type).toBe('bitcoin_address');
  });
  
  it('classifies Lightning invoices', () => {
    const result = classifyQRCode('lnbc123...');
    expect(result.type).toBe('lightning_invoice');
  });
  
  it('handles lightning: prefix', () => {
    const result = classifyQRCode('lightning:lnbc123...');
    expect(result.type).toBe('lightning_invoice');
    expect(result.value).toBe('lnbc123...');
  });
  
  it('classifies Lightning addresses', () => {
    const result = classifyQRCode('user@example.com');
    expect(result.type).toBe('lightning_address');
  });
  
  it('classifies Cashu tokens', () => {
    const result = classifyQRCode('cashuA...');
    expect(result.type).toBe('cashu_token');
  });
  
  it('classifies npubs', () => {
    const result = classifyQRCode('npub1abc...');
    expect(result.type).toBe('npub');
  });
  
  it('handles nostr: prefix', () => {
    const result = classifyQRCode('nostr:npub1abc...');
    expect(result.type).toBe('npub');
    expect(result.value).toBe('npub1abc...');
  });
  
  it('returns unknown for unrecognized content', () => {
    const result = classifyQRCode('random text');
    expect(result.type).toBe('unknown');
  });
});

Integration Tests

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useQRCodeScanner } from '@/hooks/useQRCodeScanner';
import { Html5Qrcode } from 'html5-qrcode';

vi.mock('html5-qrcode');

describe('useQRCodeScanner', () => {
  beforeEach(() => {
    vi.mocked(Html5Qrcode.getCameras).mockResolvedValue([
      { id: 'camera1', label: 'Back Camera' }
    ]);
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  it('starts scanning successfully', async () => {
    const mockScanner = {
      start: vi.fn().mockResolvedValue(undefined),
      stop: vi.fn().mockResolvedValue(undefined),
      clear: vi.fn().mockResolvedValue(undefined),
      getState: vi.fn().mockReturnValue(2), // SCANNING
    };
    
    vi.mocked(Html5Qrcode).mockImplementation(() => mockScanner as any);

    const { result } = renderHook(() => useQRCodeScanner());

    await result.current.startScanning(vi.fn());

    expect(mockScanner.start).toHaveBeenCalled();
    expect(result.current.isScanning).toBe(true);
  });

  it('handles camera permission errors', async () => {
    vi.mocked(Html5Qrcode.getCameras).mockRejectedValue(
      new Error('NotAllowedError: Permission denied')
    );

    const { result } = renderHook(() => useQRCodeScanner());

    await result.current.startScanning(vi.fn());

    expect(result.current.error).toContain('Camera permission denied');
    expect(result.current.isScanning).toBe(false);
  });

  it('stops scanning correctly', async () => {
    const mockScanner = {
      start: vi.fn().mockResolvedValue(undefined),
      stop: vi.fn().mockResolvedValue(undefined),
      clear: vi.fn().mockResolvedValue(undefined),
      getState: vi.fn().mockReturnValue(2), // SCANNING
    };
    
    vi.mocked(Html5Qrcode).mockImplementation(() => mockScanner as any);

    const { result } = renderHook(() => useQRCodeScanner());

    await result.current.startScanning(vi.fn());
    await result.current.stopScanning();

    expect(mockScanner.stop).toHaveBeenCalled();
    expect(mockScanner.clear).toHaveBeenCalled();
    expect(result.current.isScanning).toBe(false);
  });
});

Security Considerations

  1. Validate scanned content - Don't trust QR code content blindly, always validate format
  2. Sanitize before display - Escape HTML in scanned text before showing to users
  3. Rate limit scanning - Prevent rapid-fire scans that could be used for abuse
  4. Handle malicious QR codes - Some QR codes can contain malicious URLs or data
  5. Verify content types - Double-check classified content matches expected format before processing

Verification Checklist

  • [ ] Scanner starts and stops correctly
  • [ ] Camera permissions handled gracefully
  • [ ] Content classification accurate for all types
  • [ ] Scanner cleanup on component unmount
  • [ ] Error messages are user-friendly
  • [ ] Works on mobile devices
  • [ ] Handles camera switching (front/rear)
  • [ ] Scanner state managed correctly
  • [ ] No memory leaks from scanner instances
  • [ ] Paste from clipboard works correctly
  • [ ] All content types route to correct handlers
  • [ ] Unknown content handled gracefully
  • [ ] Duplicate scans prevented

Summary

To implement QR code scanning:

  1. Install package - html5-qrcode
  2. Classify content - Route scanned content to correct handlers
  3. Scan QR codes - Use Html5Qrcode with camera access
  4. Handle permissions - Provide clear error messages for camera access
  5. Add paste functionality - Provide clipboard paste as alternative
  6. Clean up - Always stop scanner on unmount
  7. Test thoroughly - Test all content types and error cases

Key principle: Classify scanned content before processing to ensure correct routing and validation.