diff --git a/README.md b/README.md index 62e56e8b..ae942a47 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Two caveats worth stating plainly, since "everything is local" is the headline c - **QR Code Generator**: Generate QR codes from text, URLs, contact information, and more. - **Barcode Generator**: Generate various barcode types including CODE128, EAN-13, UPC-A, CODE39, and more. - **QR Code Scanner**: Scan QR codes using your camera or upload an image. +- **Airgap QR Transfer**: Stream and transfer files securely between devices using animated QR codes without internet, local network, or cables. - **Barcode Scanner**: Scan barcodes using your camera or upload an image. - **Charts**: Create beautiful, customizable charts with full control over every detail. - **YAML ↔ JSON**: Convert between YAML and JSON formats instantly. diff --git a/bun.lockb b/bun.lockb index aaea90bb..67b8ac9f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/messages/ar.json b/messages/ar.json index 5557da52..20b7d82b 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -349,8 +349,12 @@ "description": "أنشئ رموز QR من نصوص أو روابط أو أي بيانات." }, "qr-scanner": { - "name": "قارئ رمز QR", - "description": "امسح وفكّ رموز QR من الكاميرا أو الصور المرفوعة." + "name": "ماسح رموز QR", + "description": "امسح رموز QR وفك تشفيرها من الكاميرا أو الصور المرفوعة." + }, + "airgap-transfer": { + "name": "نقل الملفات المعزول (Airgap QR)", + "description": "نقل الملفات بأمان بين الأجهزة المعزولة دون إنترنت عبر بث رموز QR المتحركة." }, "barcode-generator": { "name": "مولّد الباركود", diff --git a/messages/en.json b/messages/en.json index 5abdf786..454c08a9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -352,6 +352,10 @@ "name": "QR Scanner", "description": "Scan and decode QR codes from your camera or uploaded images." }, + "airgap-transfer": { + "name": "Airgap QR Transfer", + "description": "Stream and transfer files securely between offline devices via animated QR codes." + }, "barcode-generator": { "name": "Barcode Generator", "description": "Generate barcodes in multiple formats: EAN, UPC, Code128, and more." diff --git a/src/__tests__/lib/airgap-protocol.test.ts b/src/__tests__/lib/airgap-protocol.test.ts new file mode 100644 index 00000000..058466bf --- /dev/null +++ b/src/__tests__/lib/airgap-protocol.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { + prepareFileForAirgap, + parseAirgapPacket, + compressBytes, + decompressBytes, + computeChecksum, +} from "@/lib/airgap-protocol"; + +describe("airgap-protocol", () => { + it("compresses and decompresses byte buffers correctly", async () => { + const text = "BrowseryTools AirQA fast airgap transfer protocol test string repeated many times ".repeat(20); + const original = new TextEncoder().encode(text); + const compressed = await compressBytes(original); + expect(compressed.length).toBeLessThan(original.length); + + const decompressed = await decompressBytes(compressed); + expect(new TextDecoder().decode(decompressed)).toBe(text); + }); + + it("splits a file into valid AG1 packets and parses them back", async () => { + const content = "Confidential air-gapped payload ".repeat(50); + const blob = new Blob([content], { type: "text/plain" }); + const file = new File([blob], "secret-document.txt", { type: "text/plain" }); + + const { meta, packets } = await prepareFileForAirgap(file, 200); + expect(packets.length).toBeGreaterThan(0); + expect(meta.fileName).toBe("secret-document.txt"); + expect(meta.totalChunks).toBe(packets.length); + + const parsedPackets = packets.map((p) => parseAirgapPacket(p)); + for (const parsed of parsedPackets) { + expect(parsed).not.toBeNull(); + expect(parsed?.fileName).toBe("secret-document.txt"); + expect(parsed?.totalChunks).toBe(packets.length); + } + + // Reconstruct + const sorted = [...parsedPackets].sort((a, b) => a!.chunkIdx - b!.chunkIdx); + const totalLen = sorted.reduce((acc, p) => acc + p!.data.length, 0); + const merged = new Uint8Array(totalLen); + let offset = 0; + for (const p of sorted) { + merged.set(p!.data, offset); + offset += p!.data.length; + } + + const reconstructed = sorted[0]!.isCompressed ? await decompressBytes(merged) : merged; + expect(new TextDecoder().decode(reconstructed)).toBe(content); + }); + + it("computes 8-character checksum correctly", async () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + const hash = await computeChecksum(data); + expect(hash).toHaveLength(8); + }); +}); diff --git a/src/app/tools/airgap-transfer/opengraph-image.tsx b/src/app/tools/airgap-transfer/opengraph-image.tsx new file mode 100644 index 00000000..54bccee4 --- /dev/null +++ b/src/app/tools/airgap-transfer/opengraph-image.tsx @@ -0,0 +1,9 @@ +import { generateToolOgImage, ogSize, ogContentType } from "@/lib/og-image"; + +export const alt = "airgap-transfer | BrowseryTools"; +export const size = ogSize; +export const contentType = ogContentType; + +export default function Image() { + return generateToolOgImage("airgap-transfer"); +} diff --git a/src/app/tools/airgap-transfer/page.tsx b/src/app/tools/airgap-transfer/page.tsx new file mode 100644 index 00000000..bfb4dd6c --- /dev/null +++ b/src/app/tools/airgap-transfer/page.tsx @@ -0,0 +1,8 @@ +import { generateToolMetadata } from "@/lib/metadata"; +import AirgapTransfer from "@/components/AirgapTransfer"; + +export const metadata = generateToolMetadata("/tools/airgap-transfer"); + +export default function AirgapTransferPage() { + return ; +} diff --git a/src/app/tools/airgap-transfer/twitter-image.tsx b/src/app/tools/airgap-transfer/twitter-image.tsx new file mode 100644 index 00000000..54bccee4 --- /dev/null +++ b/src/app/tools/airgap-transfer/twitter-image.tsx @@ -0,0 +1,9 @@ +import { generateToolOgImage, ogSize, ogContentType } from "@/lib/og-image"; + +export const alt = "airgap-transfer | BrowseryTools"; +export const size = ogSize; +export const contentType = ogContentType; + +export default function Image() { + return generateToolOgImage("airgap-transfer"); +} diff --git a/src/components/AirgapTransfer.tsx b/src/components/AirgapTransfer.tsx new file mode 100644 index 00000000..052b106f --- /dev/null +++ b/src/components/AirgapTransfer.tsx @@ -0,0 +1,658 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import QRCode from "qrcode"; +import { + Camera, + QrCode, + CheckCircle2, + AlertCircle, + Play, + Square, + RefreshCw, + Download, + Maximize2, + Minimize2, + Send, + Radio, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ToolShell } from "@/components/template/tool-shell"; +import { ModePicker } from "@/components/shared/ModePicker"; +import { FileDropzone } from "@/components/shared/FileDropzone"; +import { StatStrip } from "@/components/shared/StatStrip"; +import { SliderRow } from "@/components/shared/SliderRow"; +import { OptionRow, SettingsCard } from "@/components/shared/SettingsCard"; +import { downloadBlob } from "@/lib/download"; +import { + prepareFileForAirgap, + parseAirgapPacket, + decompressBytes, + type AirgapMeta, +} from "@/lib/airgap-protocol"; + +type TransferMode = "send" | "receive" | "pair"; + +export default function AirgapTransfer() { + const [mode, setMode] = useState("send"); + + // --- Sender State --- + const [file, setFile] = useState(null); + const [meta, setMeta] = useState(null); + const [packets, setPackets] = useState([]); + const [currentPacketIdx, setCurrentPacketIdx] = useState(0); + const [isSending, setIsSending] = useState(false); + const [fps, setFps] = useState(10); + const [chunkSize, setChunkSize] = useState(450); + const [senderDataUrl, setSenderDataUrl] = useState(""); + const [isFullscreen, setIsFullscreen] = useState(false); + + // --- Receiver State --- + const [isReceiving, setIsReceiving] = useState(false); + const [receivedChunks, setReceivedChunks] = useState>(new Map()); + const [incomingMeta, setIncomingMeta] = useState<{ + fileId: string; + fileName: string; + fileSize: number; + totalChunks: number; + isCompressed: boolean; + } | null>(null); + const [cameraStream, setCameraStream] = useState(null); + const [cameraError, setCameraError] = useState(null); + const [completedBlob, setCompletedBlob] = useState<{ blob: Blob; fileName: string } | null>(null); + + // Telemetry metrics + const [startTime, setStartTime] = useState(null); + const [lastScanTime, setLastScanTime] = useState(null); + const [instantSpeedKBps, setInstantSpeedKBps] = useState(0); + + // Pairing QR + const [pairUrlQr, setPairUrlQr] = useState(""); + + const videoRef = useRef(null); + const canvasRef = useRef(null); + const scanLoopRef = useRef(null); + const senderIntervalRef = useRef(null); + + // Generate pairing QR (direct link to receive mode) + useEffect(() => { + if (typeof window !== "undefined") { + const url = `${window.location.origin}${window.location.pathname}#receive`; + QRCode.toDataURL(url, { width: 220, margin: 1, errorCorrectionLevel: "M" }) + .then(setPairUrlQr) + .catch(console.error); + } + }, []); + + // Hash route listener (#receive opens receiver mode) + useEffect(() => { + if (typeof window !== "undefined" && window.location.hash === "#receive") { + setMode("receive"); + } + }, []); + + // Prepare sender file + const handleFileSelect = async (files: File[]) => { + if (!files.length) return; + const selected = files[0]; + setFile(selected); + try { + const prep = await prepareFileForAirgap(selected, chunkSize); + setMeta(prep.meta); + setPackets(prep.packets); + setCurrentPacketIdx(0); + setIsSending(false); + + if (prep.packets.length > 0) { + const url = await QRCode.toDataURL(prep.packets[0], { + width: 380, + margin: 1, + errorCorrectionLevel: "L", + }); + setSenderDataUrl(url); + } + } catch (err) { + console.error("Failed to prepare file:", err); + } + }; + + // Sender stream loop + useEffect(() => { + if (!isSending || packets.length === 0) { + if (senderIntervalRef.current) clearInterval(senderIntervalRef.current); + return; + } + + let idx = currentPacketIdx; + const intervalMs = Math.max(40, Math.floor(1000 / fps)); + + senderIntervalRef.current = setInterval(async () => { + idx = (idx + 1) % packets.length; + setCurrentPacketIdx(idx); + try { + const url = await QRCode.toDataURL(packets[idx], { + width: 380, + margin: 1, + errorCorrectionLevel: "L", + }); + setSenderDataUrl(url); + } catch (err) { + console.error("QR render error:", err); + } + }, intervalMs); + + return () => { + if (senderIntervalRef.current) clearInterval(senderIntervalRef.current); + }; + }, [isSending, packets, fps, currentPacketIdx]); + + // Start / Stop Camera Receiver + const startCamera = async () => { + setCameraError(null); + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } }, + }); + setCameraStream(stream); + if (videoRef.current) { + videoRef.current.srcObject = stream; + videoRef.current.play(); + } + setIsReceiving(true); + setStartTime(Date.now()); + setLastScanTime(Date.now()); + } catch (err: any) { + console.error("Camera access failed:", err); + setCameraError(err?.message || "Camera permission denied or camera not found"); + } + }; + + const stopCamera = useCallback(() => { + if (cameraStream) { + cameraStream.getTracks().forEach((track) => track.stop()); + setCameraStream(null); + } + if (scanLoopRef.current) { + cancelAnimationFrame(scanLoopRef.current); + scanLoopRef.current = null; + } + setIsReceiving(false); + }, [cameraStream]); + + // Clean up camera on unmount + useEffect(() => { + return () => { + if (cameraStream) { + cameraStream.getTracks().forEach((track) => track.stop()); + } + if (scanLoopRef.current) { + cancelAnimationFrame(scanLoopRef.current); + } + if (senderIntervalRef.current) { + clearInterval(senderIntervalRef.current); + } + }; + }, [cameraStream]); + + // QR Scanning Loop using jsQR + useEffect(() => { + if (!isReceiving || !videoRef.current || !cameraStream) return; + + let active = true; + + const scanFrame = async () => { + if (!active) return; + const video = videoRef.current; + const canvas = canvasRef.current; + + if (video && video.readyState === video.HAVE_ENOUGH_DATA && canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + try { + const { default: jsQR } = await import("jsqr"); + const code = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: "dontInvert", + }); + + if (code && code.data.startsWith("AG1|")) { + const packet = parseAirgapPacket(code.data); + if (packet) { + setIncomingMeta((prev) => { + if (!prev || prev.fileId !== packet.fileId) { + // Reset chunks for new file stream + setReceivedChunks(new Map([[packet.chunkIdx, packet.data]])); + return { + fileId: packet.fileId, + fileName: packet.fileName, + fileSize: packet.fileSize, + totalChunks: packet.totalChunks, + isCompressed: packet.isCompressed, + }; + } + return prev; + }); + + setReceivedChunks((prevMap) => { + if (prevMap.has(packet.chunkIdx)) return prevMap; + const newMap = new Map(prevMap); + newMap.set(packet.chunkIdx, packet.data); + + // Update telemetry metrics + const now = Date.now(); + setLastScanTime((last) => { + if (last && now > last) { + const deltaSec = (now - last) / 1000; + if (deltaSec > 0.05) { + const speed = packet.data.length / 1024 / deltaSec; + setInstantSpeedKBps(parseFloat(speed.toFixed(1))); + } + } + return now; + }); + + return newMap; + }); + } + } + } catch (err) { + console.error("Scanner frame error:", err); + } + } + } + + if (active) { + scanLoopRef.current = requestAnimationFrame(scanFrame); + } + }; + + scanLoopRef.current = requestAnimationFrame(scanFrame); + + return () => { + active = false; + if (scanLoopRef.current) { + cancelAnimationFrame(scanLoopRef.current); + } + }; + }, [isReceiving, cameraStream]); + + // Assembly and Auto-Download on Complete + useEffect(() => { + if (!incomingMeta) return; + if (receivedChunks.size >= incomingMeta.totalChunks && incomingMeta.totalChunks > 0) { + const assembleAndComplete = async () => { + const sortedIndices = Array.from({ length: incomingMeta.totalChunks }, (_, i) => i); + let totalLen = 0; + for (const idx of sortedIndices) { + const chunk = receivedChunks.get(idx); + if (chunk) totalLen += chunk.length; + } + + const merged = new Uint8Array(totalLen); + let offset = 0; + for (const idx of sortedIndices) { + const chunk = receivedChunks.get(idx); + if (chunk) { + merged.set(chunk, offset); + offset += chunk.length; + } + } + + let finalBytes: Uint8Array = merged; + if (incomingMeta.isCompressed) { + finalBytes = await decompressBytes(merged); + } + + const blob = new Blob([finalBytes.buffer as ArrayBuffer]); + setCompletedBlob({ blob, fileName: incomingMeta.fileName }); + downloadBlob(blob, incomingMeta.fileName); + stopCamera(); + }; + + assembleAndComplete(); + } + }, [receivedChunks, incomingMeta, stopCamera]); + + // Estimated Time Remaining calculation + const receiverProgress = useMemo(() => { + if (!incomingMeta || incomingMeta.totalChunks === 0) return 0; + return Math.min(100, Math.round((receivedChunks.size / incomingMeta.totalChunks) * 100)); + }, [receivedChunks.size, incomingMeta]); + + const etaSeconds = useMemo(() => { + if (!incomingMeta || receivedChunks.size === 0 || !startTime) return null; + const elapsed = (Date.now() - startTime) / 1000; + const rate = receivedChunks.size / Math.max(1, elapsed); + const remainingChunks = incomingMeta.totalChunks - receivedChunks.size; + if (remainingChunks <= 0) return 0; + return Math.ceil(remainingChunks / Math.max(0.1, rate)); + }, [incomingMeta, receivedChunks.size, startTime]); + + return ( + + + {/* Mode Switcher in Stage Header */} + { + setMode(val); + if (val !== "receive") stopCamera(); + }} + aria-label="Transfer Mode" + /> + + {/* ================= SENDER MODE ================= */} + {mode === "send" && ( + + + + + {meta && ( + + )} + + + + + { + const sz = parseInt(val, 10); + setChunkSize(sz); + if (file) handleFileSelect([file]); + }} + > + + + + + Low Density (250B - Best for low-res cameras) + Medium Density (450B - Recommended) + High Density (800B - High speed) + + + + + + + + {senderDataUrl ? ( + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + {isFullscreen && ( + setIsFullscreen(false)} + > + Exit Fullscreen + + )} + + + + + + Packet: {currentPacketIdx + 1} / {packets.length} + + {fps} FPS (~{((chunkSize * fps) / 1024).toFixed(1)} KB/s) + + + + + + setIsSending(!isSending)} + variant={isSending ? "destructive" : "default"} + > + {isSending ? ( + <> + Pause Loop + > + ) : ( + <> + Start Streaming + > + )} + + setIsFullscreen(!isFullscreen)} + title="Fullscreen QR" + > + + + + + ) : ( + + + Select a file above to generate the QR optical stream. + + )} + + + )} + + {/* ================= RECEIVER MODE ================= */} + {mode === "receive" && ( + + + + + + + {/* Overlay target frame */} + {isReceiving && ( + + + + )} + + {!isReceiving && !cameraError && ( + + + Point your camera at the sender's screen. + + Open Camera & Scan + + + )} + + {cameraError && ( + + + {cameraError} + + Retry Camera + + + )} + + + {isReceiving && ( + + + Stop Camera + + + )} + + + + + Incoming Transmission + {incomingMeta ? ( + + + + {incomingMeta.fileName} + + {(incomingMeta.fileSize / 1024).toFixed(1)} KB + + + + + + + + {/* Packet Reception Grid Indicator */} + + + Packet Reception Matrix ({receivedChunks.size}/{incomingMeta.totalChunks}) + + + {Array.from({ length: incomingMeta.totalChunks }).map((_, i) => ( + + ))} + + + + ) : ( + + Waiting for incoming optical signal... + + )} + + + {completedBlob && ( + + + + + Transfer Complete! + {completedBlob.fileName} + + + downloadBlob(completedBlob.blob, completedBlob.fileName)} + > + Save File + + + )} + + + )} + + {/* ================= PAIR DEVICE ================= */} + {mode === "pair" && ( + + Scan to Open Receiver on Another Device + + Scan this QR code with your phone or second computer to open the receiver mode directly. + + {pairUrlQr && ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + )} + + No internet connection required once page is loaded + + + )} + + + ); +} diff --git a/src/components/layout/tool-title.tsx b/src/components/layout/tool-title.tsx index 90f5a5b2..ffe27039 100644 --- a/src/components/layout/tool-title.tsx +++ b/src/components/layout/tool-title.tsx @@ -131,6 +131,7 @@ const HAS_OWN_H1 = new Set([ // loan-calculator, percentage-calculator, roman-numeral, tip-calculator) had // their own h1 before and are unchanged here. "age-calculator", + "airgap-transfer", "calculator", "number-base-converter", "unit-converter", diff --git a/src/lib/airgap-protocol.ts b/src/lib/airgap-protocol.ts new file mode 100644 index 00000000..061fc1fc --- /dev/null +++ b/src/lib/airgap-protocol.ts @@ -0,0 +1,184 @@ +/** + * Airgap QR Data Streaming Protocol + * + * Designed for screen-to-camera transmission without network connectivity. + * Features: + * - Chunking & optional Gzip compression via browser standard CompressionStream / DecompressionStream + * - Sequential packet carousel with frame indexing and SHA-256 integrity check + * - Compact base64 packet framing: "AG1|||||||" + */ + +export interface AirgapMeta { + fileId: string; + fileName: string; + fileSize: number; + compressedSize: number; + totalChunks: number; + chunkSize: number; + checksum: string; // First 8 hex chars of SHA-256 +} + +export interface AirgapPacket { + version: "AG1"; + fileId: string; + chunkIdx: number; + totalChunks: number; + fileSize: number; + fileName: string; + dataBase64: string; +} + +// Compress data with gzip using Web Streams API +export async function compressBytes(data: Uint8Array): Promise { + if (typeof CompressionStream === "undefined") { + return data; // Fallback if not supported + } + try { + const stream = new Response( + new Blob([data.buffer as ArrayBuffer]).stream().pipeThrough(new CompressionStream("gzip")) + ); + const buffer = await stream.arrayBuffer(); + return new Uint8Array(buffer); + } catch (err) { + console.warn("Compression failed, using uncompressed:", err); + return data; + } +} + +// Decompress gzip data using Web Streams API +export async function decompressBytes(data: Uint8Array): Promise { + if (typeof DecompressionStream === "undefined") { + return data; + } + try { + const stream = new Response( + new Blob([data.buffer as ArrayBuffer]).stream().pipeThrough(new DecompressionStream("gzip")) + ); + const buffer = await stream.arrayBuffer(); + return new Uint8Array(buffer); + } catch (err) { + console.warn("Decompression failed, returning raw bytes:", err); + return data; + } +} + +// Compute SHA-256 short hash +export async function computeChecksum(data: Uint8Array): Promise { + if (typeof crypto === "undefined" || !crypto.subtle) { + return "00000000"; + } + const hashBuffer = await crypto.subtle.digest("SHA-256", data.buffer as ArrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 8); +} + +// Convert bytes to base64 safely +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + const len = bytes.byteLength; + const chunkSize = 0x8000; + for (let i = 0; i < len; i += chunkSize) { + const sub = bytes.subarray(i, Math.min(i + chunkSize, len)); + binary += String.fromCharCode.apply(null, Array.from(sub)); + } + return btoa(binary); +} + +// Convert base64 to bytes safely +export function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// Prepare file chunks for transmission +export async function prepareFileForAirgap( + file: File, + chunkSize: number = 450 +): Promise<{ meta: AirgapMeta; packets: string[] }> { + const rawBytes = new Uint8Array(await file.arrayBuffer()); + const checksum = await computeChecksum(rawBytes); + const compressed = await compressBytes(rawBytes); + const isCompressed = compressed.length < rawBytes.length; + const payload = isCompressed ? compressed : rawBytes; + + const totalChunks = Math.max(1, Math.ceil(payload.length / chunkSize)); + const fileId = Math.random().toString(36).substring(2, 8); + + const meta: AirgapMeta = { + fileId, + fileName: file.name, + fileSize: file.size, + compressedSize: payload.length, + totalChunks, + chunkSize, + checksum, + }; + + const packets: string[] = []; + const safeFileName = encodeURIComponent(file.name); + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, payload.length); + const chunkBytes = payload.subarray(start, end); + const b64 = bytesToBase64(chunkBytes); + + // Format: AG1|fileId|chunkIdx|totalChunks|fileSize|safeFileName|flags|b64 + const flags = isCompressed ? "z" : "r"; + const packet = `AG1|${fileId}|${i}|${totalChunks}|${file.size}|${safeFileName}|${flags}|${b64}`; + packets.push(packet); + } + + return { meta, packets }; +} + +export interface ParsedPacket { + version: "AG1"; + fileId: string; + chunkIdx: number; + totalChunks: number; + fileSize: number; + fileName: string; + isCompressed: boolean; + data: Uint8Array; +} + +// Parse an incoming QR string +export function parseAirgapPacket(raw: string): ParsedPacket | null { + if (!raw.startsWith("AG1|")) return null; + const parts = raw.split("|"); + if (parts.length < 8) return null; + + const [, fileId, chunkIdxStr, totalChunksStr, fileSizeStr, safeFileName, flags, b64] = parts; + const chunkIdx = parseInt(chunkIdxStr, 10); + const totalChunks = parseInt(totalChunksStr, 10); + const fileSize = parseInt(fileSizeStr, 10); + const fileName = decodeURIComponent(safeFileName); + const isCompressed = flags === "z"; + + if (isNaN(chunkIdx) || isNaN(totalChunks) || isNaN(fileSize)) { + return null; + } + + try { + const data = base64ToBytes(b64); + return { + version: "AG1", + fileId, + chunkIdx, + totalChunks, + fileSize, + fileName, + isCompressed, + data, + }; + } catch (err) { + console.error("Failed to decode packet payload:", err); + return null; + } +} diff --git a/src/lib/tool-content.ts b/src/lib/tool-content.ts index adc7df0e..66c99aea 100644 --- a/src/lib/tool-content.ts +++ b/src/lib/tool-content.ts @@ -1059,6 +1059,54 @@ export const toolContent: Record = { }, }, + "airgap-transfer": { + related: ["qr-generator", "qr-scanner", "text-encryption", "file-converter"], + en: { + intro: + "Airgap QR Transfer lets you move files securely between two offline devices using animated QR code streams. By converting file data into compressed base64 packets and streaming them optically from screen to camera, files can be transferred without internet access, Wi-Fi, Bluetooth, or physical cables.", + whyClientSide: + "Traditional file transfer tools require cloud relays, local network socket connections, or Bluetooth pairing. Airgap QR Transfer is 100% optical and air-gapped — your data is compressed and rendered as QR frames right on your display, leaving zero network trace.", + steps: [ + "Select Send mode and drop the file you wish to transfer.", + "On the receiving device, select Receive mode and grant camera access (or scan the Pair QR).", + "Point the receiving camera at the sender screen and click 'Start Streaming'.", + "The receiving client reconstructs the file packets in real time and automatically saves the completed file.", + ], + limitations: [ + "Optimal transfer speeds range between 5 KB/s to 30 KB/s depending on screen brightness, camera refresh rate, and resolution.", + "Best suited for documents, cryptographic keys, small archives, seeds, and certificates rather than large multi-gigabyte video files.", + ], + faq: [ + { + q: "How does air-gapped QR file transfer work?", + a: "The tool splits your file into compressed chunks and encodes them sequentially into animated QR codes. The receiving device's camera scans these QR frames in real time, buffers each packet, and reassembles the original file once all parts are received.", + }, + { + q: "Does this require any internet or Wi-Fi connection?", + a: "No. Once the web app page is loaded, the transfer operates 100% locally and optically via screen and camera.", + }, + ], + }, + ar: { + intro: + "تتيح لك أداة نقل البيانات المعزولة (Airgap QR Transfer) نقل الملفات بأمان بين جهازين غير متصلين بالإنترنت عبر بث متحرك لرموز QR. يتم ضغط البيانات وتمريرها ضوئياً من الشاشة إلى الكاميرا دون شبكة أو بلوتوث أو كابلات.", + whyClientSide: + "تعمل الأداة بالكامل داخل المتصفح وبشكل معزول تماماً عن أي شبكة أو خادم، مما يجعلها مثالية للملفات الحساسة والمفاتيح المشفرة.", + steps: [ + "اختر وضع الإرسال واسحب الملف المراد نقله.", + "في الجهاز المستلم، افتح وضع الاستلام وشغّل الكاميرا.", + "وجّه الكاميرا نحو الشاشة لبدء قراءة حزم البيانات ضوئياً.", + "يتم تجميع الملف وفك ضغطه تلقائياً فور اكتمال جميع الحزم.", + ], + faq: [ + { + q: "هل تتطلب الأداة اتصالاً بالإنترنت؟", + a: "كلا، بمجرد تحميل الصفحة تعمل الأداة بشكل معزول كلياً عبر الكاميرا والشاشة دون أي اتصال شبكي.", + }, + ], + }, + }, + "qr-generator": { related: ["barcode-generator", "qr-scanner", "url-encoder", "base64"], en: { diff --git a/src/lib/tool-synonyms.ts b/src/lib/tool-synonyms.ts index 41dced1c..1aafe6f6 100644 --- a/src/lib/tool-synonyms.ts +++ b/src/lib/tool-synonyms.ts @@ -26,6 +26,8 @@ */ export const TOOL_SYNONYMS: Record = { // --- AI Tools cluster --- + "qr-generator": ["make qr code", "create qr code", "qr code maker", "url to qr"], + "airgap-transfer": ["airgap transfer", "qr file transfer", "offline file transfer", "airqa", "optical file transfer", "screen to camera transfer"], "audio-transcriber": ["speech to text", "audio to text", "transcribe audio", "convert voice to text", "whisper transcription", "captions from audio"], "text-summarizer": ["summarize text", "tldr generator", "shorten an article", "condense text", "summary tool"], translator: ["translate text", "language translator", "translate offline", "on-device translation"], diff --git a/src/lib/tools-config.ts b/src/lib/tools-config.ts index 85544171..4fc400e8 100644 --- a/src/lib/tools-config.ts +++ b/src/lib/tools-config.ts @@ -1042,6 +1042,16 @@ export const tools: ToolCategory[] = [ description: "Scan QR codes using your camera or upload an image. Get instant results with copy and download options. Perfect for quickly accessing URLs, contact info, and other QR data.", }, + { + name: "Airgap QR Transfer", + href: "/tools/airgap-transfer", + icon: QrCodeIcon, + available: true, + order: 5.5, + creationDate: "2026-08-16", + description: + "Stream and transfer files securely between devices using animated QR codes without internet, local network, or cables.", + }, { name: "Barcode Scanner", href: "/tools/barcode-scanner",
Select a file above to generate the QR optical stream.
Point your camera at the sender's screen.
{cameraError}
Transfer Complete!
{completedBlob.fileName}
+ Scan this QR code with your phone or second computer to open the receiver mode directly. +