From 640e99b8a3ffadf5cbedd085194c003c129ddc94 Mon Sep 17 00:00:00 2001 From: Aghyad Date: Tue, 1 Sep 2026 16:38:02 +0400 Subject: [PATCH] feat(tools): add airgap qr file transfer tool --- README.md | 1 + bun.lockb | Bin 521176 -> 531360 bytes messages/ar.json | 8 +- messages/en.json | 4 + src/__tests__/lib/airgap-protocol.test.ts | 57 ++ .../tools/airgap-transfer/opengraph-image.tsx | 9 + src/app/tools/airgap-transfer/page.tsx | 8 + .../tools/airgap-transfer/twitter-image.tsx | 9 + src/components/AirgapTransfer.tsx | 658 ++++++++++++++++++ src/components/layout/tool-title.tsx | 1 + src/lib/airgap-protocol.ts | 184 +++++ src/lib/tool-content.ts | 48 ++ src/lib/tool-synonyms.ts | 2 + src/lib/tools-config.ts | 10 + 14 files changed, 997 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/lib/airgap-protocol.test.ts create mode 100644 src/app/tools/airgap-transfer/opengraph-image.tsx create mode 100644 src/app/tools/airgap-transfer/page.tsx create mode 100644 src/app/tools/airgap-transfer/twitter-image.tsx create mode 100644 src/components/AirgapTransfer.tsx create mode 100644 src/lib/airgap-protocol.ts 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 aaea90bba3778f6309f00dd480049c5840021041..67b8ac9f21a6d13b7936dc99f90ba2e86ae71aec 100755 GIT binary patch delta 22669 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 F{~v_6ybAyT delta 21904 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 UDx1+?>BW*~IZD~==W?U}2abWq$^ZZW 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 && ( + + )} + + + + + + + +
+ +
+ {senderDataUrl ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Airgap QR Frame + {isFullscreen && ( + + )} +
+ +
+
+ + Packet: {currentPacketIdx + 1} / {packets.length} + + {fps} FPS (~{((chunkSize * fps) / 1024).toFixed(1)} KB/s) +
+ +
+ +
+ + +
+
+ ) : ( +
+ +

Select a file above to generate the QR optical stream.

+
+ )} +
+
+ )} + + {/* ================= RECEIVER MODE ================= */} + {mode === "receive" && ( +
+
+
+
+ +
+
+

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}

+
+
+ +
+ )} +
+
+ )} + + {/* ================= 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 */} + Pairing QR +
+ )} + + 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",