How it works
The QR codes are the wire.
There is no network in this picture. A terminal draws frames, a camera reads them, and nothing travels between the two machines except light.
01 — The path
File in, file out
Five stages on the sending side, four on the receiving side, and an air gap in the middle that no packet crosses.
Sender
Read
The file is read whole, or piped in on stdin.
Chunk
Split to fit one QR code, sized from the terminal height.
Encode
Plain text, base64 for binary, gzip, or fountain symbols.
Frame
Each chunk gets a pipe-separated header and becomes a QR code.
Render
A ratatui slideshow you can scrub, pause and gap-fill.
Receiver
Scan
Continuous detection — no shutter, no trigger.
Parse
The header identifies the transfer and the frame type.
Assemble
A worker isolate peels fountain symbols and writes chunks to disk.
Verify
SHA-256 against the CHECKSUM frame, then save.
Sending machine Receiving device
(Rust binary) (Flutter app)
read file
chunk it
encode
build QR frames
render slideshow ──── light ────► camera
decode QR
parse frame
assemble
verify → save02 — The constraint
A link with no back-channel
The receiver cannot ask for a frame again. Everything interesting about the design follows from that one fact.
What the sender knows
Nothing. It does not know whether anyone is scanning, how much has been caught, or whether the receiver exists. It draws frames until you stop it.
What the receiver can ask for
Nothing. A frame lost to glare, blur or a bad angle is simply gone. There is no retransmit request, because there is no channel to send one on.
Every other design decision follows from this. It is why fountain coding is in the box, why the checksum travels in-band, and why the receiver writes chunks to disk as it goes instead of holding them until the end.
03 — Two encodings
Sequential, or fountain
Sequential frames are file slices in order. Fountain frames are XORs of a random subset, so any sufficient pile of them rebuilds the file.
Sequential
Frame n is byte range n of the file. Simple, and enough for a few hundred chunks. Miss one and you wait for the sender to loop back to exactly that index — or drive it there yourself with gap-fill mode.
Fountain
Each frame is the XOR of a pseudo-random subset of the file's blocks, chosen from the frame's sequence number by a PRNG both sides run independently. Collect enough and peeling recovers everything. No specific frame matters.
F|seq|K|fileSize|id|payload
// seq which blocks this symbol XORs together
// K how many source blocks the file has
// fileSize so the receiver can trim the zero padding
// id two chars, derived from the file's digest The catch is that "enough" is more than K. Peeling needs between 1.33× and 1.89× the number of source blocks, so a progress bar scaled to K sits at 99% with a third of the scanning still to do.
04 — Getting it back
Scan, or serve, or join
The camera is the normal path. When you already have the bytes on disk, two subcommands finish the job without one.
Scan
The normal path. Point the app at the terminal; it assembles, verifies and saves, resuming from disk if it is killed partway.
serve
An HTTP receiver, for when a network turns out to exist after all. Takes raw uploads, multipart, and QR-scan JSON alike.
join
Reassembles the .partaa files a receiver wrote, checking them against the recorded digest.