Architecture

Overview

The two programs, the one-way link between them, and where each stage of a transfer happens.

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/.

ModuleResponsibility
cli.rsHand-rolled flag parsing — ~15 simple flags don't warrant a dependency
chunker.rsSplits the file, builds sequential frames, derives the transfer id
fountain.rsLT-code symbol generation: PRNG, degree table, XOR
qrtypes.rsQR version/capacity sizing and ECC levels
renderer.rsDraws QR codes as terminal cells
tui.rsThe ratatui interface — grid, sidebar, status line, keybindings
serve.rsThe axum HTTP receiver
join.rsReassembles .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.

Copyright © 2026