Overview
Porter is two independent programs that never communicate except through a camera pointed at a screen.
Sending machine Receiving device
(Rust binary) (Flutter app)
read file
chunk it
encode (base64 / gzip / fountain)
build QR frames
render slideshow ──── light ────► camera
decode QR
parse frame
assemble (worker isolate)
verify SHA-256 → save
Everything below the arrow happens with no knowledge of the sender's state, and everything above it happens with no knowledge of the receiver's. There is no handshake and no acknowledgement.
Sender
A single static Rust binary, rust-sender/.
| Module | Responsibility |
|---|---|
cli.rs | Hand-rolled flag parsing — ~15 simple flags don't warrant a dependency |
chunker.rs | Splits the file, builds sequential frames, derives the transfer id |
fountain.rs | LT-code symbol generation: PRNG, degree table, XOR |
qrtypes.rs | QR version/capacity sizing and ECC levels |
renderer.rs | Draws QR codes as terminal cells |
tui.rs | The ratatui interface — grid, sidebar, status line, keybindings |
serve.rs | The axum HTTP receiver |
join.rs | Reassembles .partXX files with a SHA-256 check |
The TUI choice drove the language choice: ratatui/crossterm is what the
interactive controls — scrubbing, jump-to-chunk, gap-fill — are built on.
Receiver
A Flutter app for Android and macOS, flutter/.
camera → mobile_scanner → ChunkParser → worker isolate → ProgressSnapshot → UI
Assembler
FountainDecoder
ChunkStorage
The important boundary is the worker isolate. It owns the Assembler, the
FountainDecoder and all chunk and metadata disk I/O for the app's lifetime;
only small ProgressSnapshot values cross back to the UI isolate.
That shape is deliberate — see the decisions for why
a per-call compute() was the wrong answer.
Sizing
QR capacity is a function of terminal height, which means K and blockSize
are computed per run rather than fixed:
version = clamp(((rows - buffer) * 2 - 17 - 4) / 4, 1, 40)
capacity = byte-mode capacity for (version, ecc)
blockSize = floor((capacity - headerReserve) * 0.75)
K = ceil(fileSize / blockSize)
A bigger terminal means a higher version, more bytes per frame, and fewer frames. It also means resizing mid-transfer forks the stream into something the receiver cannot merge with what it already has.
The Rust sender computes the header reserve exactly and forces a single Byte-mode QR segment, because the QR crate's "optimal" segmentation can charge more bits than the byte-mode capacity tables assume — a payload under the table limit could otherwise still be rejected.