+
+
+ {frames.length > 0 && (
+ <>
+
+
+
+
+
+ frame {index + 1} / {frames.length}
+
+
{file ? `${(file.bytes.length / 1024).toFixed(1)} KB` : ''}
+
+
+
+ fps
+ {SPEEDS.map((s) => (
+
+ ))}
+
+
+
+ Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is
+ picked up on the next pass.
+
+ >
+ )}
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/QrTransfer/index.ts b/src/workspaces/officerdev/src/apps/QrTransfer/index.ts
new file mode 100644
index 00000000..d1c7479c
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/QrTransfer/index.ts
@@ -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 },
+];
diff --git a/src/workspaces/officerdev/src/apps/QrTransfer/protocol.test.ts b/src/workspaces/officerdev/src/apps/QrTransfer/protocol.test.ts
new file mode 100644
index 00000000..420e4b1a
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/QrTransfer/protocol.test.ts
@@ -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());
+ });
+});
diff --git a/src/workspaces/officerdev/src/apps/QrTransfer/protocol.ts b/src/workspaces/officerdev/src/apps/QrTransfer/protocol.ts
new file mode 100644
index 00000000..2b2917be
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/QrTransfer/protocol.ts
@@ -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: