From 9b4db8fd85fac80811a5827fb69a1715429aa64d Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 19:58:08 +0700 Subject: [PATCH 1/2] docs(optical): Optical File Transfer (QR Beam) design spec Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-08-01-optical-transfer-design.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-optical-transfer-design.md diff --git a/docs/superpowers/specs/2026-08-01-optical-transfer-design.md b/docs/superpowers/specs/2026-08-01-optical-transfer-design.md new file mode 100644 index 0000000..de3cc5b --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-optical-transfer-design.md @@ -0,0 +1,73 @@ +# Optical File Transfer ("QR Beam") — Design + +**Date:** 2026-08-01 +**Tool:** Network → Optical File Transfer (`/tools/optical-transfer`) — NEW +**Type:** New tool +**Icon:** `ScanLine` +**Category:** Network + +Inspired by Decimen Optical Transfer (MIT). Transfer a file between two devices using **only +a screen and a camera** — no network at all. The sender shows an animated stream of QR codes; +the receiver's camera reads them and reconstructs the file. Even more "no server" than the +WebRTC P2P transfer — works air-gapped. + +## Core idea — LT fountain coding + +The camera will miss/blur frames, so we can't send "block 1, block 2, …". Instead, each QR +frame is an **XOR of a pseudo-random subset of file blocks**, with the subset derived +deterministically from the frame's **sequence number** (both sides share the PRNG). The +receiver collects frames in any order until it has ~K×1.15 of them and solves the file by +peeling (belief propagation). No pairing, no ACKs, no retransmission — the sender just loops. + +## Files + +- `src/tools/optical/fountain.lib.ts` (pure, TDD) — the LT codec: + - `mulberry32(seed)` deterministic PRNG. + - `robustSoliton(K)` degree distribution + `sampleDegree(rng, dist)`. + - `frameIndices(seq, K)` → deterministic block indices for a frame (seed rng with seq). + - `bytesToBlocks(bytes, blockSize)` / `blocksToBytes(blocks, size)`. + - `LtEncoder(blocks)` → `frame(seq): Uint8Array` (XOR of `frameIndices` blocks). + - `LtDecoder(k, blockSize)` → `addFrame(seq, payload): boolean` (done?), `progress()`, `recover(): Uint8Array`. + Peeling decoder: reduce each equation by solved blocks; when a frame reaches degree 1, + solve + cascade. +- `src/tools/optical/frame.lib.ts` (pure, TDD) — wire format for one QR frame: + - `encodeFrame({ session, k, size, seq, payload })` → bytes (magic, version, session u16, + k u24, size u32, seq u32, payload). `decodeFrame(bytes)` → parsed | null (validates magic). + - `fnv1a(bytes)` small hash for a session id + integrity. +- `src/tools/optical/qr.lib.ts` — QR render (qrcode, byte-mode segment) + jsqr decode wrappers + (browser; build + manual smoke). +- `src/hooks/useOpticalReceive.ts` — camera loop: grab frame → jsqr → decodeFrame → decoder → + progress → complete. Reuses camera handling. +- `src/islands/network/OpticalTransfer.tsx` — role picker; Send (Dropzone → animated QR, + looping seq via rAF at a target fps) / Receive (camera + progress + download). +- `src/registry/tools.ts` — register `optical-transfer` (Network, `ScanLine`, beta). + +## Parameters + +- **Block size** ~256 bytes (payload fits a mid-density QR that scans reliably from a phone). +- **QR:** byte-mode segment, ECC level **L** (max capacity; fountain coding already handles loss). +- **Frame rate:** target ~10 fps sender (configurable); receiver decodes as fast as it can. +- **Throughput (v1, jsqr):** modest (~KB/s) — great for text, keys, configs, small images. A + future zxing-wasm + Worker upgrade would raise it (that's what Decimen uses for ~129 KB/s). + A soft size cap warns for large files. + +## Decoding correctness + +Robust soliton produces enough degree-1 frames to bootstrap peeling and keeps overhead near +~10-15%. `frameIndices` MUST be identical on both sides (deterministic PRNG seeded by seq +only). The frame carries `k`/`size`/`session` so the receiver is fully autonomous from any +frame. A session hash guards against mixing frames from a different transfer. + +## Testing (Vitest — the whole codec is headless-testable) + +- `fountain.lib.test.ts` — PRNG determinism; `frameIndices` deterministic + within range + + degree matches; **round-trip**: random bytes → blocks → generate ~K×1.3 frames (in random + order, drop some) → decode → recovers original exactly, for several sizes. Degenerate K=1. +- `frame.lib.test.ts` — encode/decode round-trip; rejects bad magic/truncated; `fnv1a` stable. +- QR/camera/island: build + manual smoke (two devices; beam a small file). + +## Out of scope (v1) + +- zxing-wasm / Worker decode (throughput upgrade — follow-up). +- Multi-file, folders, resume. +- Big files (soft-capped; it's an optical channel). From d5fe5cd64084beb6b39b3da9e08b272779e01ea9 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 20:06:19 +0700 Subject: [PATCH 2/2] feat(optical-transfer): beam files device-to-device via QR (no network) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Network tool (/tools/optical-transfer), inspired by Decimen Optical Transfer (MIT). Transfer a file between two devices using only a screen + camera — no network, no server, fully client-side. - LT fountain codec (fountain.lib): each QR frame is the XOR of a pseudo-random subset of file blocks, subset derived deterministically from the frame seq (shared mulberry32 PRNG + robust-soliton degree distribution). The receiver peels frames in any order until all blocks solve — tolerant of dropped/blurred frames, no ACKs or retransmission. Fully unit-tested (round-trips across sizes, out-of-order, lossy, duplicate frames). - Self-describing frame wire format (frame.lib): magic/version/session/k/size/ hash/seq + payload; packFile/unpackFile carry the filename through the codec; fnv1a integrity. Tested. - qr.lib: fast synchronous QR render (qrcode byte-mode matrix) + jsqr decode. - Island: role picker; Sender loops an animated QR at ~8fps; Receiver uses the camera, decodes frames, shows progress, verifies the checksum, downloads. - Registered optical-transfer (Network, ScanLine, beta). Spec: docs/superpowers/specs/2026-08-01-optical-transfer-design.md 564 tests · lint clean · build green. Codec fully tested; QR/camera pipeline is build + manual two-device smoke. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/islands/network/OpticalTransfer.tsx | 212 ++++++++++++++++++++++++ src/registry/tools.ts | 11 ++ src/tools/optical/fountain.lib.test.ts | 101 +++++++++++ src/tools/optical/fountain.lib.ts | 185 +++++++++++++++++++++ src/tools/optical/frame.lib.test.ts | 56 +++++++ src/tools/optical/frame.lib.ts | 86 ++++++++++ src/tools/optical/qr.lib.ts | 46 +++++ 7 files changed, 697 insertions(+) create mode 100644 src/islands/network/OpticalTransfer.tsx create mode 100644 src/tools/optical/fountain.lib.test.ts create mode 100644 src/tools/optical/fountain.lib.ts create mode 100644 src/tools/optical/frame.lib.test.ts create mode 100644 src/tools/optical/frame.lib.ts create mode 100644 src/tools/optical/qr.lib.ts diff --git a/src/islands/network/OpticalTransfer.tsx b/src/islands/network/OpticalTransfer.tsx new file mode 100644 index 0000000..8644ff6 --- /dev/null +++ b/src/islands/network/OpticalTransfer.tsx @@ -0,0 +1,212 @@ +import { useEffect, useRef, useState } from 'react'; +import { Upload, Camera, RefreshCw } from 'lucide-react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ProgressBar } from '@/components/ui/ProgressBar'; +import { downloadService } from '@/services/download'; +import { useCamera } from '@/hooks/useCamera'; +import { renderQr, decodeQr } from '@/tools/optical/qr.lib'; +import { encodeFrame, decodeFrame, fnv1a, packFile, unpackFile } from '@/tools/optical/frame.lib'; +import { bytesToBlocks, blocksToBytes, LtEncoder, LtDecoder } from '@/tools/optical/fountain.lib'; + +type Role = 'send' | 'receive' | null; + +const BLOCK_SIZE = 200; // payload bytes per frame (frame ≈ 218 B → a phone-scannable QR) +const SEND_FPS = 8; +const BIG_FILE = 256 * 1024; // warn beyond this — the optical channel is slow +const CAPTURE_W = 720; // downscale camera frames for faster decoding + +function randU16(): number { + return Math.floor((crypto.getRandomValues(new Uint16Array(1))[0])); +} + +export default function OpticalTransfer() { + const [role, setRole] = useState(null); + + return ( +
+ {role === null && ( +
+

+ Transfer a file between two devices with just a screen and a camera — no network, no + accounts, nothing sent to any server. One device shows animated QR codes; the other reads them. +

+
+ + +
+
+ )} + + {role === 'send' && setRole(null)} />} + {role === 'receive' && setRole(null)} />} +
+ ); +} + +function Sender({ onBack }: { onBack: () => void }) { + const canvasRef = useRef(null); + const encoderRef = useRef(null); + const metaRef = useRef<{ session: number; k: number; size: number; hash: number } | null>(null); + const seqRef = useRef(0); + const rafRef = useRef(null); + const lastRef = useRef(0); + + const [info, setInfo] = useState<{ name: string; size: number; k: number } | null>(null); + const [big, setBig] = useState(false); + + const stop = () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); rafRef.current = null; }; + useEffect(() => () => stop(), []); + + const onDrop = async (files: File[]) => { + const file = files[0]; + if (!file) return; + stop(); + const data = new Uint8Array(await file.arrayBuffer()); + const container = packFile(file.name, data); + const blocks = bytesToBlocks(container, BLOCK_SIZE); + encoderRef.current = new LtEncoder(blocks); + metaRef.current = { session: randU16(), k: blocks.length, size: container.length, hash: fnv1a(container) }; + seqRef.current = 0; + setInfo({ name: file.name, size: file.size, k: blocks.length }); + setBig(container.length > BIG_FILE); + + const tick = (t: number) => { + if (t - lastRef.current >= 1000 / SEND_FPS && canvasRef.current && encoderRef.current && metaRef.current) { + lastRef.current = t; + const seq = seqRef.current++; + const payload = encoderRef.current.frame(seq); + renderQr(canvasRef.current, encodeFrame({ ...metaRef.current, seq, payload })); + } + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + }; + + return ( +
+ + {!info && ( + +
+

Drop a file to beam

+

Best for small files (text, keys, docs, small images) · stays on your device

+
+
+ )} + {info && ( +
+

{info.name} · {info.k} blocks

+ {big &&

⚠️ This file is on the large side for an optical transfer — it may take several minutes. Keep both devices steady.

} +
+ +
+

Point the other device's camera at this code. It loops until the file is received.

+ +
+ )} +
+ ); +} + +function Receiver({ onBack }: { onBack: () => void }) { + const { videoRef, stream, error, start, stop } = useCamera(); + const captureRef = useRef(null); + const decoderRef = useRef(null); + const metaRef = useRef<{ session: number; k: number; size: number; hash: number } | null>(null); + const rafRef = useRef(null); + + const [progress, setProgress] = useState(0); + const [frames, setFrames] = useState(0); + const [result, setResult] = useState<{ blob: Blob; name: string } | null>(null); + const [failed, setFailed] = useState(''); + + useEffect(() => { start(); return () => { stop(); if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }; }, [start, stop]); + useEffect(() => { if (stream && videoRef.current) videoRef.current.play().catch(() => {}); }, [stream, videoRef]); + + useEffect(() => { + if (!stream) return; + if (!captureRef.current) captureRef.current = document.createElement('canvas'); + let collected = 0; + + const scan = () => { + const video = videoRef.current; + const canvas = captureRef.current; + if (video && canvas && video.videoWidth > 0 && !result) { + const scale = Math.min(1, CAPTURE_W / video.videoWidth); + const w = Math.round(video.videoWidth * scale); + const h = Math.round(video.videoHeight * scale); + canvas.width = w; canvas.height = h; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (ctx) { + ctx.drawImage(video, 0, 0, w, h); + const bytes = decodeQr(ctx.getImageData(0, 0, w, h)); + const frame = bytes && decodeFrame(bytes); + if (frame) { + if (!metaRef.current) { + metaRef.current = { session: frame.session, k: frame.k, size: frame.size, hash: frame.hash }; + decoderRef.current = new LtDecoder(frame.k, BLOCK_SIZE); + } + if (frame.session === metaRef.current.session && decoderRef.current) { + const before = decoderRef.current.solvedCount; + const done = decoderRef.current.addFrame(frame.seq, frame.payload); + if (decoderRef.current.solvedCount !== before || decoderRef.current.progress() > 0) { + collected++; + setFrames(collected); + setProgress(decoderRef.current.progress()); + } + if (done) finish(); + } + } + } + } + rafRef.current = requestAnimationFrame(scan); + }; + + const finish = () => { + const meta = metaRef.current!; + const container = blocksToBytes(decoderRef.current!.recover(), meta.size); + if (fnv1a(container) !== meta.hash) { setFailed('Received the file but its checksum didn’t match — try again.'); return; } + const { name, data } = unpackFile(container); + setResult({ blob: new Blob([data]), name: name || 'received-file' }); + stop(); + }; + + rafRef.current = requestAnimationFrame(scan); + return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }; + }, [stream, videoRef, stop, result]); + + const download = () => { if (result) downloadService.download(result.blob, result.name); }; + + return ( +
+ + {error && {error.message}} + {failed && {failed}} + + {!result && ( + <> +
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 9672631..808aa85 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -586,6 +586,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/network/VideoCall'), status: 'beta' }, + { + id: 'optical-transfer', + name: 'Optical File Transfer', + category: 'Network', + route: '/tools/optical-transfer', + keywords: ['optical', 'qr', 'transfer', 'beam', 'camera', 'screen', 'offline', 'air-gap', 'no network', 'fountain', 'file'], + icon: ScanLine, + summary: 'Beam a file device-to-device with QR codes — no network at all', + load: () => import('@/islands/network/OpticalTransfer'), + status: 'beta' + }, { id: 'file-crypt', name: 'File Encrypt / Decrypt', diff --git a/src/tools/optical/fountain.lib.test.ts b/src/tools/optical/fountain.lib.test.ts new file mode 100644 index 0000000..b48b8a0 --- /dev/null +++ b/src/tools/optical/fountain.lib.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { + mulberry32, + frameIndices, + bytesToBlocks, + blocksToBytes, + LtEncoder, + LtDecoder, +} from './fountain.lib'; + +function seededBytes(n: number, seed: number): Uint8Array { + const rng = mulberry32(seed); + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) out[i] = Math.floor(rng() * 256); + return out; +} + +describe('mulberry32', () => { + it('is deterministic for a given seed', () => { + const a = mulberry32(123); + const b = mulberry32(123); + expect([a(), a(), a()]).toEqual([b(), b(), b()]); + }); + it('differs across seeds', () => { + expect(mulberry32(1)()).not.toBe(mulberry32(2)()); + }); +}); + +describe('frameIndices', () => { + it('is deterministic and within range', () => { + const k = 20; + for (let seq = 0; seq < 50; seq++) { + const a = frameIndices(seq, k); + const b = frameIndices(seq, k); + expect(a).toEqual(b); + expect(a.length).toBeGreaterThanOrEqual(1); + expect(a.length).toBeLessThanOrEqual(k); + expect(new Set(a).size).toBe(a.length); // distinct + for (const i of a) { expect(i).toBeGreaterThanOrEqual(0); expect(i).toBeLessThan(k); } + } + }); +}); + +describe('bytesToBlocks / blocksToBytes', () => { + it('round-trips with padding', () => { + const data = seededBytes(500, 7); + const blocks = bytesToBlocks(data, 256); + expect(blocks.length).toBe(2); + expect(blocks[0].length).toBe(256); + expect(Array.from(blocksToBytes(blocks, 500))).toEqual(Array.from(data)); + }); +}); + +describe('LT fountain round-trip', () => { + const blockSize = 128; + const sizes = [1, 130, 1000, 4096, 9001]; + + for (const size of sizes) { + it(`recovers ${size} bytes fed in order`, () => { + const data = seededBytes(size, size); + const enc = new LtEncoder(bytesToBlocks(data, blockSize)); + const dec = new LtDecoder(enc.k, blockSize); + let seq = 0; + let done = false; + while (!done && seq < enc.k * 6 + 40) { done = dec.addFrame(seq, enc.frame(seq)); seq++; } + expect(done).toBe(true); + expect(Array.from(blocksToBytes(dec.recover(), size))).toEqual(Array.from(data)); + }); + } + + it('recovers with out-of-order frames and some dropped', () => { + const size = 3000; + const data = seededBytes(size, 42); + const enc = new LtEncoder(bytesToBlocks(data, blockSize)); + const dec = new LtDecoder(enc.k, blockSize); + // A big pool of seqs, shuffled deterministically, dropping every 7th. + const pool: number[] = []; + for (let s = 0; s < enc.k * 8; s++) if (s % 7 !== 0) pool.push(s); + const rng = mulberry32(99); + for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } + let done = false; + for (const s of pool) { if (done) break; done = dec.addFrame(s, enc.frame(s)); } + expect(done).toBe(true); + expect(Array.from(blocksToBytes(dec.recover(), size))).toEqual(Array.from(data)); + }); + + it('ignores duplicate frames without breaking', () => { + const data = seededBytes(600, 5); + const enc = new LtEncoder(bytesToBlocks(data, blockSize)); + const dec = new LtDecoder(enc.k, blockSize); + let seq = 0; + let done = false; + while (!done && seq < enc.k * 8) { + dec.addFrame(seq, enc.frame(seq)); // add twice + done = dec.addFrame(seq, enc.frame(seq)); + seq++; + } + expect(done).toBe(true); + expect(Array.from(blocksToBytes(dec.recover(), 600))).toEqual(Array.from(data)); + }); +}); diff --git a/src/tools/optical/fountain.lib.ts b/src/tools/optical/fountain.lib.ts new file mode 100644 index 0000000..364b759 --- /dev/null +++ b/src/tools/optical/fountain.lib.ts @@ -0,0 +1,185 @@ +/** + * LT (Luby Transform) fountain codec for the optical transfer tool. + * + * Each frame is the XOR of a pseudo-random subset of the file's blocks, with the + * subset derived deterministically from the frame's sequence number — so both the + * sender and receiver compute the same subset from `seq` alone. The receiver peels + * (belief propagation) until every block is solved, regardless of frame order/loss. + */ + +/** Deterministic PRNG (mulberry32) — same output on both devices for a given seed. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Robust-soliton CDF, memoized per block count. Index d-1 holds P(degree ≤ d). +const cdfCache = new Map(); +function robustSolitonCdf(k: number): number[] { + const cached = cdfCache.get(k); + if (cached) return cached; + + const c = 0.03; + const delta = 0.5; + const R = c * Math.log(k / delta) * Math.sqrt(k); + const pivot = Math.max(1, Math.round(k / R)); + + const rho = new Array(k + 1).fill(0); + rho[1] = 1 / k; + for (let d = 2; d <= k; d++) rho[d] = 1 / (d * (d - 1)); + + const tau = new Array(k + 1).fill(0); + for (let d = 1; d < pivot; d++) tau[d] = R / (d * k); + if (pivot <= k) tau[pivot] = (R * Math.log(R / delta)) / k; + + let z = 0; + for (let d = 1; d <= k; d++) z += rho[d] + tau[d]; + + const cdf = new Array(k).fill(0); + let acc = 0; + for (let d = 1; d <= k; d++) { + acc += (rho[d] + tau[d]) / z; + cdf[d - 1] = acc; + } + cdf[k - 1] = 1; // guard against FP drift + cdfCache.set(k, cdf); + return cdf; +} + +/** The block indices a frame combines, derived deterministically from `seq`. */ +export function frameIndices(seq: number, k: number): number[] { + if (k <= 1) return [0]; + const rng = mulberry32(seq >>> 0); + const cdf = robustSolitonCdf(k); + const r = rng(); + let degree = 1; + while (degree < k && cdf[degree - 1] < r) degree++; + + const picked = new Set(); + while (picked.size < degree) picked.add(Math.floor(rng() * k)); + return [...picked].sort((a, b) => a - b); +} + +function xorInto(target: Uint8Array, src: Uint8Array): void { + for (let i = 0; i < target.length; i++) target[i] ^= src[i]; +} + +/** Split bytes into fixed-size blocks (last one zero-padded). */ +export function bytesToBlocks(bytes: Uint8Array, blockSize: number): Uint8Array[] { + const k = Math.max(1, Math.ceil(bytes.length / blockSize)); + const blocks: Uint8Array[] = []; + for (let i = 0; i < k; i++) { + const block = new Uint8Array(blockSize); + block.set(bytes.subarray(i * blockSize, i * blockSize + blockSize)); + blocks.push(block); + } + return blocks; +} + +/** Concatenate solved blocks and trim to the original size. */ +export function blocksToBytes(blocks: Uint8Array[], size: number): Uint8Array { + const out = new Uint8Array(blocks.length * (blocks[0]?.length ?? 0)); + blocks.forEach((b, i) => out.set(b, i * b.length)); + return out.subarray(0, size); +} + +export class LtEncoder { + readonly k: number; + private blocks: Uint8Array[]; + private blockSize: number; + + constructor(blocks: Uint8Array[]) { + this.blocks = blocks; + this.k = blocks.length; + this.blockSize = blocks[0]?.length ?? 0; + } + + /** The XOR payload for frame `seq`. */ + frame(seq: number): Uint8Array { + const out = new Uint8Array(this.blockSize); + for (const i of frameIndices(seq, this.k)) xorInto(out, this.blocks[i]); + return out; + } +} + +export class LtDecoder { + readonly k: number; + private blockSize: number; + private solved: (Uint8Array | null)[]; + solvedCount = 0; + private seenSeq = new Set(); + // Outstanding equations: unsolved index set + accumulated xor value. + private equations: { indices: Set; value: Uint8Array }[] = []; + + constructor(k: number, blockSize: number) { + this.k = k; + this.blockSize = blockSize; + this.solved = new Array(k).fill(null); + } + + get done(): boolean { + return this.solvedCount >= this.k; + } + + progress(): number { + return this.k === 0 ? 1 : this.solvedCount / this.k; + } + + /** Feed one frame. Returns true once the whole file is solved. */ + addFrame(seq: number, payload: Uint8Array): boolean { + if (this.done || this.seenSeq.has(seq)) return this.done; + this.seenSeq.add(seq); + + const indices = new Set(); + const value = payload.slice(); + for (const i of frameIndices(seq, this.k)) { + if (this.solved[i]) xorInto(value, this.solved[i]!); + else indices.add(i); + } + this.reduceAndSolve(indices, value); + return this.done; + } + + private reduceAndSolve(indices: Set, value: Uint8Array): void { + const queue: { indices: Set; value: Uint8Array }[] = [{ indices, value }]; + while (queue.length) { + const eq = queue.shift()!; + // Drop indices already solved since this equation was queued. + for (const i of [...eq.indices]) { + if (this.solved[i]) { xorInto(eq.value, this.solved[i]!); eq.indices.delete(i); } + } + if (eq.indices.size === 0) continue; // redundant + if (eq.indices.size > 1) { this.equations.push(eq); continue; } + + // Degree 1 → solve this block, then cascade into other equations. + const idx = eq.indices.values().next().value as number; + if (this.solved[idx]) continue; + this.solved[idx] = eq.value; + this.solvedCount++; + + const still: { indices: Set; value: Uint8Array }[] = []; + for (const other of this.equations) { + if (other.indices.has(idx)) { + xorInto(other.value, eq.value); + other.indices.delete(idx); + if (other.indices.size <= 1) queue.push(other); + else still.push(other); + } else { + still.push(other); + } + } + this.equations = still; + } + } + + /** The k solved blocks in order (call only when `done`). */ + recover(): Uint8Array[] { + return this.solved.map(b => b ?? new Uint8Array(this.blockSize)); + } +} diff --git a/src/tools/optical/frame.lib.test.ts b/src/tools/optical/frame.lib.test.ts new file mode 100644 index 0000000..4afa0ba --- /dev/null +++ b/src/tools/optical/frame.lib.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { encodeFrame, decodeFrame, fnv1a, HEADER_SIZE, packFile, unpackFile } from './frame.lib'; + +describe('packFile / unpackFile', () => { + it('round-trips a filename + data (incl. unicode names)', () => { + const data = new Uint8Array([10, 20, 30, 40]); + const packed = packFile('résumé 📄.pdf', data); + const { name, data: out } = unpackFile(packed); + expect(name).toBe('résumé 📄.pdf'); + expect(Array.from(out)).toEqual(Array.from(data)); + }); +}); + +describe('fnv1a', () => { + it('is stable and order-sensitive', () => { + expect(fnv1a(new Uint8Array([1, 2, 3]))).toBe(fnv1a(new Uint8Array([1, 2, 3]))); + expect(fnv1a(new Uint8Array([1, 2, 3]))).not.toBe(fnv1a(new Uint8Array([3, 2, 1]))); + }); +}); + +describe('encodeFrame / decodeFrame', () => { + const meta = { session: 0xabcd, k: 42, size: 5000, hash: 0x12345678, seq: 7 }; + const payload = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + + it('round-trips a frame', () => { + const bytes = encodeFrame({ ...meta, payload }); + expect(bytes.length).toBe(HEADER_SIZE + payload.length); + const parsed = decodeFrame(bytes); + expect(parsed).not.toBeNull(); + expect(parsed!.session).toBe(meta.session); + expect(parsed!.k).toBe(meta.k); + expect(parsed!.size).toBe(meta.size); + expect(parsed!.hash >>> 0).toBe(meta.hash); + expect(parsed!.seq).toBe(meta.seq); + expect(Array.from(parsed!.payload)).toEqual(Array.from(payload)); + }); + + it('handles large u32 values', () => { + const bytes = encodeFrame({ session: 65535, k: 65535, size: 4000000000, hash: 4000000000, seq: 3000000000, payload }); + const parsed = decodeFrame(bytes)!; + expect(parsed.size).toBe(4000000000); + expect(parsed.seq).toBe(3000000000); + expect(parsed.hash >>> 0).toBe(4000000000); + }); + + it('rejects a bad magic byte', () => { + const bytes = encodeFrame({ ...meta, payload }); + bytes[0] ^= 0xff; + expect(decodeFrame(bytes)).toBeNull(); + }); + + it('rejects a truncated frame', () => { + expect(decodeFrame(new Uint8Array(HEADER_SIZE - 1))).toBeNull(); + expect(decodeFrame(new Uint8Array([]))).toBeNull(); + }); +}); diff --git a/src/tools/optical/frame.lib.ts b/src/tools/optical/frame.lib.ts new file mode 100644 index 0000000..c31106f --- /dev/null +++ b/src/tools/optical/frame.lib.ts @@ -0,0 +1,86 @@ +/** + * Wire format for one optical-transfer QR frame. Every frame is self-describing so + * the receiver can start from any frame it happens to catch. + * + * Header (18 bytes, big-endian): + * 0 magic (0xB3) + * 1 version(0x01) + * 2..3 session id (u16) + * 4..5 k = block count (u16) + * 6..9 file size (u32) + * 10..13 file hash, fnv1a (u32) + * 14..17 seq (u32) + * 18.. payload (blockSize bytes) + */ + +const MAGIC = 0xb3; +const VERSION = 0x01; +export const HEADER_SIZE = 18; + +export interface FrameMeta { + session: number; + k: number; + size: number; + hash: number; + seq: number; +} + +export interface Frame extends FrameMeta { + payload: Uint8Array; +} + +/** 32-bit FNV-1a hash. */ +export function fnv1a(bytes: Uint8Array): number { + let h = 0x811c9dc5; + for (let i = 0; i < bytes.length; i++) { + h ^= bytes[i]; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +export function encodeFrame(frame: Frame): Uint8Array { + const out = new Uint8Array(HEADER_SIZE + frame.payload.length); + const view = new DataView(out.buffer); + out[0] = MAGIC; + out[1] = VERSION; + view.setUint16(2, frame.session & 0xffff); + view.setUint16(4, frame.k & 0xffff); + view.setUint32(6, frame.size >>> 0); + view.setUint32(10, frame.hash >>> 0); + view.setUint32(14, frame.seq >>> 0); + out.set(frame.payload, HEADER_SIZE); + return out; +} + +/** Wrap a file's name + bytes into one container so both travel through the codec. */ +export function packFile(name: string, data: Uint8Array): Uint8Array { + const nameBytes = new TextEncoder().encode(name).slice(0, 65535); + const out = new Uint8Array(2 + nameBytes.length + data.length); + new DataView(out.buffer).setUint16(0, nameBytes.length); + out.set(nameBytes, 2); + out.set(data, 2 + nameBytes.length); + return out; +} + +/** Unwrap a container produced by packFile. */ +export function unpackFile(bytes: Uint8Array): { name: string; data: Uint8Array } { + if (bytes.length < 2) return { name: '', data: new Uint8Array(0) }; + const nameLen = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(0); + const name = new TextDecoder().decode(bytes.subarray(2, 2 + nameLen)); + return { name, data: bytes.slice(2 + nameLen) }; +} + +export function decodeFrame(bytes: Uint8Array): Frame | null { + if (bytes.length < HEADER_SIZE) return null; + if (bytes[0] !== MAGIC || bytes[1] !== VERSION) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + session: view.getUint16(2), + k: view.getUint16(4), + size: view.getUint32(6), + hash: view.getUint32(10), + seq: view.getUint32(14), + payload: bytes.slice(HEADER_SIZE), + }; +} diff --git a/src/tools/optical/qr.lib.ts b/src/tools/optical/qr.lib.ts new file mode 100644 index 0000000..d7f45ea --- /dev/null +++ b/src/tools/optical/qr.lib.ts @@ -0,0 +1,46 @@ +import QRCode from 'qrcode'; +import jsQR from 'jsqr'; + +// Draw a QR matrix ourselves (synchronous, fast enough to animate) rather than the +// async QRCode.toCanvas. +interface QrMatrix { modules: { size: number; data: ArrayLike } } + +/** + * Render binary `bytes` as a QR code onto `canvas`. Uses byte mode + error + * correction 'L' for maximum capacity — the fountain coding already tolerates loss. + * Returns false if the payload is too large for a single QR. + */ +export function renderQr(canvas: HTMLCanvasElement, bytes: Uint8Array, cell = 6, margin = 3): boolean { + let qr: QrMatrix; + try { + qr = QRCode.create([{ data: bytes, mode: 'byte' }], { errorCorrectionLevel: 'L' }) as unknown as QrMatrix; + } catch { + return false; // too much data for one QR + } + const size = qr.modules.size; + const data = qr.modules.data; + const dim = (size + margin * 2) * cell; + canvas.width = dim; + canvas.height = dim; + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, dim, dim); + ctx.fillStyle = '#000000'; + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + if (data[r * size + c]) ctx.fillRect((c + margin) * cell, (r + margin) * cell, cell, cell); + } + } + return true; +} + +/** The largest payload that reliably fits our QR settings (version ~20, ECC L). */ +export const MAX_QR_PAYLOAD = 800; + +/** Decode a QR from an ImageData frame, returning its raw bytes (or null). */ +export function decodeQr(image: ImageData): Uint8Array | null { + const result = jsQR(image.data, image.width, image.height, { inversionAttempts: 'dontInvert' }); + if (!result || !result.binaryData || result.binaryData.length === 0) return null; + return new Uint8Array(result.binaryData); +}