add /qr-transfer — offline file transfer over animated QR

TEMPORARY / EXPERIMENTAL, at the owner's request, after deedy/qr-data-transfer (QRFerry).
Two panels: one loops a file as QR frames, the other scans them through the camera and
rebuilds it. Entirely client-side — nothing about a transfer reaches the server, which is
the point of the technique.

NOT RaptorQ, and that is the one real design decision here. QRFerry carries RFC 6330
fountain-coded symbols so a receiver can rebuild from ANY sufficient set of frames. This
uses a plain indexed carousel instead, for two reasons — the second being the deciding one:

  1. RFC 6330 is days of work and unpleasant to debug.
  2. The sender and receiver are being reimplemented on iOS and Android. A format one person
     can re-derive from protocol.ts in an afternoon is worth more here than optical
     efficiency. Every frame is independent, self-describing, and parses with a string split.

The cost is honest and written down: without fountain coding you must eventually capture each
specific frame, so a miss waits for the next pass rather than being covered by surplus. Fine
for a few hundred KB on a steady camera; it degrades where RaptorQ would start to pay for
itself.

Details that matter for the phone implementations:
- base64url, no padding — ':' and '/' would collide with the field separator.
- CRC-32 of the whole file in the meta frame, checked after reassembly. The test pins the
  reference value for "123456789" (cbf43926) so any stock implementation will agree.
- The meta frame repeats every 12 frames, so a receiver joining late learns the filename and
  total without waiting a full cycle.
- Error correction level L: frames are short-lived and repeated forever, so QR capacity is
  better spent staying sparse enough to scan than on recovery.

16 tests over the protocol, which is the spec the other implementations should match.
The receiver needs a secure origin for camera access; over the tailnet with HTTPS that holds,
and it says so plainly rather than failing silently on plain http.

Adds qrcode and jsqr. @types/qrcode was already present and orphaned — its runtime package had
been removed with the chat-channel cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 02:17:20 +00:00
co-authored by Claude Opus 5
parent dc1b0636bf
commit 82290bb3e0
14 changed files with 785 additions and 18 deletions
+2
View File
@@ -50,6 +50,8 @@ export function App() {
<Route path="/wallet" element={<Dashboard.WalletScreen />} />
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /qr-transfer — TEMPORARY / EXPERIMENTAL.
//
// Move a file between two devices with no shared network, over animated QR codes: one panel displays a
// looping carousel of frames, the other scans it through the camera. Inspired by deedy/qr-data-transfer,
// but without its RaptorQ fountain coding — see apps/QrTransfer/protocol.ts for that trade and why it was
// made (the format is being reimplemented on iOS and Android, where simplicity beats optical efficiency).
//
// Both panels are pure client-side: nothing about a transfer reaches the server, which is the point.
// The receiver needs a secure origin for camera access — over the tailnet with HTTPS that is satisfied.
const ALLOWED_APP_TYPES = new Set<string | null>(['qr-send', 'qr-receive', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'qr-send' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
return children.some((c, i) => c !== node.children[i]) ? { ...node, children } : node;
}
export const QrTransferScreen = () => {
const rawWorkspace = useDashboardState<LayoutNode>('screens/qr-transfer', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
return <WorkspaceView workspace={workspace} locked />;
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'qr-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'qr-send', appType: 'qr-send' }, size: 50 },
{ node: { type: 'panel', id: 'qr-receive', appType: 'qr-receive' }, size: 50 },
],
};
@@ -0,0 +1 @@
export * from './QrTransferScreen';
@@ -26,3 +26,4 @@ export * from './Email';
export * from './Browser';
export * from './Desktop';
export * from './Jobs';
export * from './QrTransfer';
@@ -21,6 +21,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
@@ -14,6 +14,7 @@ import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { useAppRegistry } from './useAppRegistry';
const apps = [
@@ -33,6 +34,7 @@ const apps = [
...invoicesMetas,
...walletMetas,
...monitorMetas,
...qrTransferMetas,
];
export const AppRegistry = () => {
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import jsQR from 'jsqr';
import { Camera, Download, RotateCcw } from 'lucide-react';
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
// Receiver: camera → jsQR → collect frames until every index is present.
//
// Frames arrive in whatever order the camera happens to catch them, and duplicates are constant — the
// sender loops forever. So the state is a Map keyed by sequence number, and progress is its size against
// the total the meta frame declared.
//
// The camera is only available on a secure origin. Over the tailnet with HTTPS that is fine; on plain
// http://<ip> the browser refuses and there is nothing this code can do about it, so it says so.
type Progress = { meta: MetaFrame; have: number };
export const QrReceiver = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chunksRef = useRef(new Map<number, Uint8Array>());
const metaRef = useRef<MetaFrame | null>(null);
const rafRef = useRef<number>(0);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<Progress | null>(null);
const [done, setDone] = useState<{ url: string; name: string; ok: boolean } | null>(null);
const reset = useCallback(() => {
chunksRef.current = new Map();
metaRef.current = null;
setProgress(null);
setDone(null);
}, []);
const stop = useCallback(() => {
cancelAnimationFrame(rafRef.current);
const stream = videoRef.current?.srcObject as MediaStream | null;
stream?.getTracks().forEach((t) => t.stop());
if (videoRef.current) videoRef.current.srcObject = null;
setScanning(false);
}, []);
const handleFrame = useCallback(
(text: string) => {
const frame = decodeFrame(text);
if (!frame) return;
if (frame.kind === 'meta') {
// A different session means the sender switched files — start over rather than mixing two.
if (metaRef.current && metaRef.current.sid !== frame.sid) {
chunksRef.current = new Map();
}
metaRef.current = frame;
setProgress({ meta: frame, have: chunksRef.current.size });
return;
}
const meta = metaRef.current;
// Data before meta is unusable — we would not know how many frames to expect. It comes round again.
if (!meta || frame.sid !== meta.sid) return;
if (!chunksRef.current.has(frame.seq)) {
chunksRef.current.set(frame.seq, frame.bytes);
setProgress({ meta, have: chunksRef.current.size });
const rebuilt = reassemble(chunksRef.current, meta.total);
if (rebuilt) {
// The end-to-end check: every frame passed its own decode, but only this proves the file.
const ok = crc32(rebuilt) === meta.crc32;
const blob = new Blob([rebuilt as BlobPart], { type: meta.mime });
setDone({ url: URL.createObjectURL(blob), name: meta.name, ok });
stop();
}
}
},
[stop],
);
const tick = useCallback(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (video && canvas && video.readyState === video.HAVE_ENOUGH_DATA) {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (ctx) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
// dontInvert: our codes are always dark-on-light, and trying both doubles the work per frame.
const found = jsQR(image.data, image.width, image.height, { inversionAttempts: 'dontInvert' });
if (found?.data) handleFrame(found.data);
}
}
rafRef.current = requestAnimationFrame(tick);
}, [handleFrame]);
const start = useCallback(async () => {
setError(null);
reset();
if (!navigator.mediaDevices?.getUserMedia) {
setError('Camera needs a secure origin (https). Plain http will not work.');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setScanning(true);
rafRef.current = requestAnimationFrame(tick);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not open the camera');
}
}, [reset, tick]);
useEffect(() => stop, [stop]);
const pct = progress ? Math.round((progress.have / progress.meta.total) * 100) : 0;
return (
<div className="flex h-full w-full flex-col items-center gap-3 overflow-y-auto p-3">
{!scanning && !done && (
<button
type="button"
onClick={start}
className="flex cursor-pointer items-center gap-2 rounded-md border border-duck-dark/20 px-3 py-2 text-sm text-duck-dark/80 hover:border-duck-teal hover:text-duck-teal"
>
<Camera className="h-4 w-4" />
Start scanning
</button>
)}
{error && <p className="text-center text-xs text-red-500">{error}</p>}
<video ref={videoRef} playsInline muted className={scanning ? 'max-w-full rounded' : 'hidden'} />
<canvas ref={canvasRef} className="hidden" />
{progress && !done && (
<div className="w-full text-xs text-duck-dark/70">
<div className="mb-1 flex justify-between">
<span className="truncate">{progress.meta.name}</span>
<span>
{progress.have} / {progress.meta.total} ({pct}%)
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded bg-duck-dark/10">
<div className="h-full bg-duck-teal transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
)}
{done && (
<div className="flex flex-col items-center gap-2 text-sm">
<p className={done.ok ? 'text-emerald-600' : 'text-red-500'}>
{done.ok ? 'Complete — checksum matches' : 'Complete, but the checksum does NOT match'}
</p>
<a
href={done.url}
download={done.name}
className="flex items-center gap-2 rounded-md bg-duck-teal/15 px-3 py-2 text-duck-teal hover:bg-duck-teal/25"
>
<Download className="h-4 w-4" />
{done.name}
</a>
<button
type="button"
onClick={reset}
className="flex cursor-pointer items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-teal"
>
<RotateCcw className="h-3.5 w-3.5" />
Receive another
</button>
</div>
)}
{scanning && (
<button
type="button"
onClick={stop}
className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500"
>
Stop
</button>
)}
</div>
);
};
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Upload, Play, Pause } from 'lucide-react';
import {
chunkFile,
crc32,
encodeData,
encodeMeta,
newSessionId,
DEFAULT_CHUNK_BYTES,
} from './protocol';
// Sender: a file becomes a looping carousel of QR frames.
//
// The loop never ends on its own. A receiver joining halfway simply picks up what it can and fills the
// gaps on later passes, which is what replaces fountain coding here — see protocol.ts for why.
//
// The meta frame is re-emitted every META_EVERY frames rather than only at the start, so a receiver that
// arrives late learns the filename and total without waiting for a full cycle.
const META_EVERY = 12;
/** Frames per second. Faster is not better: past ~10 a phone camera starts missing more than it gains. */
const SPEEDS = [4, 6, 8, 12] as const;
export const QrSender = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
const [frames, setFrames] = useState<string[]>([]);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(true);
const [fps, setFps] = useState<number>(6);
const onPick = useCallback(async (picked: File) => {
const bytes = new Uint8Array(await picked.arrayBuffer());
setFile({ name: picked.name, mime: picked.type, bytes });
const chunks = chunkFile(bytes, DEFAULT_CHUNK_BYTES);
const sid = newSessionId();
const meta = encodeMeta({
sid,
total: chunks.length,
size: bytes.length,
crc32: crc32(bytes),
name: picked.name,
mime: picked.type,
});
// Build the whole carousel up front — encoding per tick would jitter the frame rate.
const built: string[] = [];
chunks.forEach((chunk, i) => {
if (i % META_EVERY === 0) built.push(meta);
built.push(encodeData(sid, i, chunk));
});
built.push(meta);
setFrames(built);
setIndex(0);
setPlaying(true);
}, []);
// Advance the carousel.
useEffect(() => {
if (!playing || frames.length === 0) return;
const timer = setInterval(() => setIndex((i) => (i + 1) % frames.length), 1000 / fps);
return () => clearInterval(timer);
}, [playing, frames.length, fps]);
// Draw the current frame.
useEffect(() => {
const canvas = canvasRef.current;
const text = frames[index];
if (!canvas || !text) return;
// Error correction L: these frames are short-lived and repeated forever, so spending QR capacity on
// recovery buys less than keeping the code sparse enough to scan quickly.
QRCode.toCanvas(canvas, text, { errorCorrectionLevel: 'L', margin: 2, width: 512 }).catch(() => {
/* a frame that fails to render is skipped; the next tick draws the following one */
});
}, [frames, index]);
return (
<div className="flex h-full w-full flex-col items-center gap-3 overflow-y-auto p-3">
<label className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-duck-dark/25 py-3 text-sm text-duck-dark/70 hover:border-duck-teal hover:text-duck-teal">
<Upload className="h-4 w-4" />
{file ? file.name : 'Choose a file to send'}
<input
type="file"
className="hidden"
onChange={(ev) => {
const picked = ev.target.files?.[0];
if (picked) void onPick(picked);
}}
/>
</label>
{frames.length > 0 && (
<>
<canvas ref={canvasRef} className="max-w-full rounded bg-white" />
<div className="flex items-center gap-3 text-xs text-duck-dark/60">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
className="flex cursor-pointer items-center gap-1 hover:text-duck-teal"
>
{playing ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
{playing ? 'Pause' : 'Play'}
</button>
<span>
frame {index + 1} / {frames.length}
</span>
<span>{file ? `${(file.bytes.length / 1024).toFixed(1)} KB` : ''}</span>
</div>
<div className="flex items-center gap-1 text-xs">
<span className="text-duck-dark/40">fps</span>
{SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setFps(s)}
className={`cursor-pointer rounded px-1.5 py-0.5 ${
fps === s ? 'bg-duck-teal/15 text-duck-teal' : 'text-duck-dark/50 hover:text-duck-teal'
}`}
>
{s}
</button>
))}
</div>
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40">
Keep this on screen until the receiver reports 100%. It loops forever a missed frame is
picked up on the next pass.
</p>
</>
)}
</div>
);
};
@@ -0,0 +1,17 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { QrCode, ScanLine } from 'lucide-react';
import { QrSender } from './Sender';
import { QrReceiver } from './Receiver';
export { QrSender } from './Sender';
export { QrReceiver } from './Receiver';
export * from './protocol';
// TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes, so a device with no shared
// network can still receive a file. See ./protocol.ts for the wire format, which is deliberately simple
// enough to reimplement on iOS and Android.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'qr-send', name: 'QR Send', icon: QrCode, component: QrSender },
{ key: 'qr-receive', name: 'QR Receive', icon: ScanLine, component: QrReceiver },
];
@@ -0,0 +1,140 @@
import { describe, test, expect } from 'bun:test';
import {
toB64url,
fromB64url,
crc32,
encodeMeta,
encodeData,
decodeFrame,
chunkFile,
reassemble,
newSessionId,
} from './protocol';
// The wire format is about to be reimplemented in Swift and Kotlin, so these tests are the spec: any
// other implementation should be able to reproduce them exactly.
const bytes = (...n: number[]) => new Uint8Array(n);
describe('base64url', () => {
test('round-trips every byte value', () => {
const all = new Uint8Array(256);
for (let i = 0; i < 256; i++) all[i] = i;
expect(Array.from(fromB64url(toB64url(all)))).toEqual(Array.from(all));
});
// The three lengths that exercise each padding case.
test.each([[bytes(1)], [bytes(1, 2)], [bytes(1, 2, 3)]])('round-trips length %#', (input) => {
expect(Array.from(fromB64url(toB64url(input)))).toEqual(Array.from(input));
});
test('emits no character that collides with the separator', () => {
const all = new Uint8Array(256).map((_, i) => i);
const encoded = toB64url(all);
expect(encoded).not.toContain(':');
expect(encoded).not.toContain('/');
expect(encoded).not.toContain('+');
expect(encoded).not.toContain('=');
});
});
describe('crc32', () => {
// The standard check value: CRC-32 of "123456789" is 0xCBF43926 in every stock implementation.
test('matches the reference value for "123456789"', () => {
expect(crc32(new TextEncoder().encode('123456789'))).toBe('cbf43926');
});
test('is 8 hex characters even when it has leading zeros', () => {
expect(crc32(new Uint8Array(0))).toHaveLength(8);
});
});
describe('frames', () => {
test('meta round-trips, including a name with unicode', () => {
const encoded = encodeMeta({
sid: 'a1b2c3d4',
total: 42,
size: 24_000,
crc32: 'cbf43926',
name: 'relatório ✅.pdf',
mime: 'application/pdf',
});
const f = decodeFrame(encoded);
expect(f?.kind).toBe('meta');
if (f?.kind !== 'meta') throw new Error('wrong kind');
expect(f).toEqual({
kind: 'meta',
sid: 'a1b2c3d4',
total: 42,
size: 24_000,
crc32: 'cbf43926',
name: 'relatório ✅.pdf',
mime: 'application/pdf',
});
});
test('data round-trips exact bytes', () => {
const payload = bytes(0, 255, 58, 47, 43, 61, 10, 13);
const f = decodeFrame(encodeData('a1b2c3d4', 7, payload));
expect(f?.kind).toBe('data');
if (f?.kind !== 'data') throw new Error('wrong kind');
expect(f.seq).toBe(7);
expect(Array.from(f.bytes)).toEqual(Array.from(payload));
});
// A camera sees every QR code in view, not just ours.
test('ignores foreign QR content', () => {
expect(decodeFrame('https://example.com')).toBeNull();
expect(decodeFrame('QRF2:D:x:0:AAAA')).toBeNull();
expect(decodeFrame('')).toBeNull();
});
test('survives a mime type containing a colon', () => {
const f = decodeFrame(encodeMeta({ sid: 's', total: 1, size: 1, crc32: 'x', name: 'a', mime: 'x:y' }));
expect(f?.kind === 'meta' && f.mime).toBe('x:y');
});
});
describe('chunk and reassemble', () => {
const file = new Uint8Array(2500).map((_, i) => i % 256);
test('chunks cover the file exactly and rebuild it', () => {
const chunks = chunkFile(file, 600);
expect(chunks).toHaveLength(5); // 600*4 + 100
expect(chunks[4]!.length).toBe(100);
const map = new Map(chunks.map((c, i) => [i, c]));
const rebuilt = reassemble(map, chunks.length)!;
expect(Array.from(rebuilt)).toEqual(Array.from(file));
expect(crc32(rebuilt)).toBe(crc32(file));
});
// The carousel's whole premise: frames arrive in any order, over several passes.
test('order does not matter', () => {
const chunks = chunkFile(file, 600);
const shuffled = new Map([...chunks.entries()].reverse().map(([i, c]) => [i, c]));
expect(Array.from(reassemble(shuffled, chunks.length)!)).toEqual(Array.from(file));
});
test('returns null while any frame is still missing', () => {
const chunks = chunkFile(file, 600);
const map = new Map(chunks.map((c, i) => [i, c]));
map.delete(2);
expect(reassemble(map, chunks.length)).toBeNull();
});
test('handles a file smaller than one chunk', () => {
const small = bytes(1, 2, 3);
const chunks = chunkFile(small, 600);
expect(chunks).toHaveLength(1);
expect(Array.from(reassemble(new Map([[0, chunks[0]!]]), 1)!)).toEqual([1, 2, 3]);
});
});
describe('session ids', () => {
test('are 8 hex characters and differ between transfers', () => {
const a = newSessionId();
expect(a).toMatch(/^[0-9a-f]{8}$/);
expect(a).not.toBe(newSessionId());
});
});
@@ -0,0 +1,182 @@
// The QR transfer wire format.
//
// TEMPORARY / EXPERIMENTAL. Inspired by deedy/qr-data-transfer (QRFerry), which carries RFC 6330
// RaptorQ fountain-coded symbols so a receiver can join mid-stream and rebuild from *any* sufficient
// set of frames. This is deliberately NOT that.
//
// WHY NOT RAPTORQ. Two reasons, and the second is the real one:
// 1. RFC 6330 is days of work to implement correctly and is unpleasant to debug.
// 2. The sender and receiver are being reimplemented on iOS and Android. A format one person can
// re-derive from this file in an afternoon is worth far more here than optical efficiency. Every
// frame is independent, self-describing and parseable with a string split.
//
// WHAT WE GIVE UP. Without fountain coding you must eventually capture each specific frame, so a missed
// frame waits for the next pass of the carousel rather than being covered by any surplus symbol. The
// sender loops forever; the receiver fills its gaps over successive passes. For a few hundred KB over a
// steady camera this converges fine. It degrades badly for large files on a shaky camera, which is the
// point at which RaptorQ starts to earn its complexity.
//
// ── FRAME FORMAT ────────────────────────────────────────────────────────────────────────────────────
// Text, QR byte mode, ':' separated. Two kinds, distinguished by the second field:
//
// META: QRF1:M:<sid>:<total>:<size>:<crc32>:<b64url(name)>:<mime>
// DATA: QRF1:D:<sid>:<seq>:<b64url(chunk)>
//
// sid 8 hex chars, identifies one transfer so a receiver ignores a stream it is not following
// total number of DATA frames
// size original byte length, for progress and a final sanity check
// crc32 CRC-32 of the WHOLE original file, hex — the end-to-end integrity check
// seq 0-based frame index
//
// Base64URL (no padding) because ':' and '/' would collide with the separator and '+' is awkward in
// some QR decoders. The meta frame is re-emitted periodically so a receiver that joins late gets it.
// ─────────────────────────────────────────────────────────────────────────────────────────────────────
export const PROTOCOL = 'QRF1';
/**
* Raw bytes per DATA frame, before base64. 600 → ~800 base64 chars, comfortably inside a QR that a
* phone camera resolves at arm's length. Larger frames mean fewer passes but a denser code that a
* mediocre camera starts to miss, which costs more than it saves.
*/
export const DEFAULT_CHUNK_BYTES = 600;
export type MetaFrame = {
kind: 'meta';
sid: string;
total: number;
size: number;
crc32: string;
name: string;
mime: string;
};
export type DataFrame = { kind: 'data'; sid: string; seq: number; bytes: Uint8Array };
export type Frame = MetaFrame | DataFrame;
// ── base64url, byte-exact in both directions ──
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
export function toB64url(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]!;
const b = bytes[i + 1];
const c = bytes[i + 2];
out += B64_CHARS[a >> 2];
out += B64_CHARS[((a & 3) << 4) | ((b ?? 0) >> 4)];
if (b === undefined) break;
out += B64_CHARS[((b & 15) << 2) | ((c ?? 0) >> 6)];
if (c === undefined) break;
out += B64_CHARS[c & 63];
}
return out;
}
export function fromB64url(s: string): Uint8Array {
const out: number[] = [];
let buffer = 0;
let bits = 0;
for (const ch of s) {
const v = B64_CHARS.indexOf(ch);
if (v < 0) continue;
buffer = (buffer << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push((buffer >> bits) & 0xff);
}
}
return new Uint8Array(out);
}
// ── CRC-32, the same polynomial everyone else uses, so the phone side can use any stock implementation ──
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c >>> 0;
}
return t;
})();
export function crc32(bytes: Uint8Array): string {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
return ((c ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
}
// ── encode ──
export function encodeMeta(m: Omit<MetaFrame, 'kind'>): string {
const name = toB64url(new TextEncoder().encode(m.name));
return [PROTOCOL, 'M', m.sid, m.total, m.size, m.crc32, name, m.mime || 'application/octet-stream'].join(':');
}
export function encodeData(sid: string, seq: number, bytes: Uint8Array): string {
return [PROTOCOL, 'D', sid, seq, toB64url(bytes)].join(':');
}
// ── decode ──
/** Returns null for anything that is not one of our frames — the camera sees plenty of other QR codes. */
export function decodeFrame(text: string): Frame | null {
if (!text.startsWith(`${PROTOCOL}:`)) return null;
const parts = text.split(':');
if (parts[1] === 'M' && parts.length >= 8) {
const [, , sid, total, size, crc, name, ...mime] = parts;
return {
kind: 'meta',
sid: sid!,
total: Number(total),
size: Number(size),
crc32: crc!,
name: new TextDecoder().decode(fromB64url(name!)),
// mime can itself contain ':' in exotic cases; rejoin whatever is left.
mime: mime.join(':') || 'application/octet-stream',
};
}
if (parts[1] === 'D' && parts.length >= 5) {
return { kind: 'data', sid: parts[2]!, seq: Number(parts[3]), bytes: fromB64url(parts[4]!) };
}
return null;
}
// ── helpers shared by both sides ──
export function chunkFile(bytes: Uint8Array, chunkBytes = DEFAULT_CHUNK_BYTES): Uint8Array[] {
const chunks: Uint8Array[] = [];
for (let i = 0; i < bytes.length; i += chunkBytes) chunks.push(bytes.subarray(i, i + chunkBytes));
return chunks;
}
export function newSessionId(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(4)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Reassemble once every index is present. Returns null while any are still missing. */
export function reassemble(chunks: Map<number, Uint8Array>, total: number): Uint8Array | null {
if (chunks.size !== total) return null;
let length = 0;
for (let i = 0; i < total; i++) {
const c = chunks.get(i);
if (!c) return null;
length += c.length;
}
const out = new Uint8Array(length);
let offset = 0;
for (let i = 0; i < total; i++) {
out.set(chunks.get(i)!, offset);
offset += chunks.get(i)!.length;
}
return out;
}