[{"data":1,"prerenderedAt":225},["ShallowReactive",2],{"project:porter":3},{"id":4,"title":5,"body":6,"demo":190,"description":191,"docs":192,"draft":193,"extension":194,"highlights":195,"image":205,"language":206,"meta":207,"navigation":208,"order":209,"path":210,"repo":211,"seo":212,"site":190,"stack":213,"stem":219,"tagline":220,"tags":221,"__hash__":224},"projects\u002Fprojects\u002Fporter.md","Porter",{"type":7,"value":8,"toc":180},"minimark",[9,14,18,21,25,33,45,60,64,67,118,122,129,140,143,147,173,177],[10,11,13],"h2",{"id":12},"the-problem-it-solves","The problem it solves",[15,16,17],"p",{},"Some machines are not on a network, and are not going to be. The usual answers\nare a USB stick you are not allowed to plug in, or a support ticket. Porter\nuses the one channel that is always open: the screen is already showing you\ndata, and you already have a camera.",[15,19,20],{},"A terminal sender renders the file as a slideshow of QR frames. A Flutter app\npoints a camera at that screen, decodes frames as they go past, and\nreassembles the original bytes — verified against a SHA-256 the sender\ntransmits in a final frame.",[10,22,24],{"id":23},"why-it-is-on-this-page","Why it is on this page",[15,26,27,28,32],{},"Because the interesting problem is not \"draw a QR code\". It is that ",[29,30,31],"strong",{},"a camera\nfilming a screen drops frames",", unpredictably and in bursts. A naive\nsequential transfer means the receiver must eventually catch frame 48,213\nspecifically, and will sit there through several full loops waiting for it.",[15,34,35,36,39,40,44],{},"Porter's answer is a ",[29,37,38],{},"Luby Transform fountain code",". Every frame is the XOR\nof a pseudo-random subset of source blocks, so no individual frame is special\n— collect enough of ",[41,42,43],"em",{},"any"," of them and the file decodes. The subset is derived\nfrom the frame's sequence number by a PRNG both sides run independently, so\nthe mapping is never transmitted.",[15,46,47,48,51,52,59],{},"That constraint is the spine of the project: an xorshift32 PRNG and a\nrobust-soliton degree table must produce ",[29,49,50],{},"bit-identical"," results in Rust and\nDart, or transfers fail silently. It is enforced with shared fixtures and\ndocumented as a normative\n",[53,54,58],"a",{"href":55,"rel":56},"https:\u002F\u002Fporter.goranninkovic.com\u002Fdocs\u002Freference\u002Fwire-format",[57],"nofollow","wire-format spec",".",[10,61,63],{"id":62},"what-real-usage-taught-it","What real usage taught it",[15,65,66],{},"Most of the hard bugs only appeared at scale, on a genuine 115 MB transfer\nwith 70,965 source blocks — none were visible at test sizes:",[68,69,70,80,95,109],"ul",{},[71,72,73,76,77,59],"li",{},[29,74,75],{},"A quadratic endgame."," Every peeled block rescanned the entire pending\npool, so decoding collapsed from ~190,000 symbols\u002Fs to 64\u002Fs right at the\nfinish, freezing the UI for 78 seconds at a stretch. A reverse index from\nblock to dependent symbols made it linear: ",[29,78,79],{},"99.4 s → 1.2 s",[71,81,82,85,86,90,91,94],{},[29,83,84],{},"Boxed integer lists."," Dart's ",[87,88,89],"code",{},"List\u003Cint>"," costs roughly 8 bytes per byte.\nSwitching the symbol buffers to ",[87,92,93],{},"Uint8List"," cut decoder memory from 158 MB\nto 56 MB, measured.",[71,96,97,100,101,104,105,108],{},[29,98,99],{},"A 32-bit overflow in the degree table."," ",[87,102,103],{},"i * (i - 1)"," wraps past\n",[87,106,107],{},"i ≈ 65536",", silently corrupting the probability distribution in release\nbuilds for any large file.",[71,110,111,114,115,59],{},[29,112,113],{},"Content-hashed transfer IDs are not session IDs."," The ID derives from the\nfile's SHA-256, so re-sending the same file at a different terminal size\nreuses it with a different block size — and the two streams are mutually\nundecodable. Transfer identity had to become ",[87,116,117],{},"(id, K, blockSize)",[10,119,121],{"id":120},"down-to-one-runtime","Down to one runtime",[15,123,124,125,128],{},"The sender began as TypeScript, which meant the ",[41,126,127],{},"sending"," machine needed Node\ninstalled — precisely the machine least likely to have it, on a tool whose\nwhole premise is that it is cut off from everything.",[15,130,131,132,135,136,139],{},"It moved to Rust for a single static binary, and ",[87,133,134],{},"ratatui"," bought the sender\ncontrols the hand-rolled ANSI renderer could not: scrubbing, jump-to-chunk, and\na gap-fill mode that loops only the frames a receiver reports missing. The last\nsubcommand to hold out was ",[87,137,138],{},"join",", pure local file concatenation with a\nchecksum — no wire-format surface, so nothing to keep in sync. Porting it let\nthe Node package and its entire lint\u002Fformat toolchain be deleted outright.",[15,141,142],{},"The repo is now two languages instead of three, with no JavaScript anywhere in\nit. The wire format shrank from three implementations to two, which is one\nfewer place for a bit to drift.",[10,144,146],{"id":145},"design-constraints","Design constraints",[68,148,149,155,161,167],{},[71,150,151,154],{},[29,152,153],{},"The receiver never blocks on the UI thread."," Decoding, peeling and disk\nI\u002FO live in a worker isolate; only small progress snapshots cross back.",[71,156,157,160],{},[29,158,159],{},"Progress must be honest."," Fountain decoding recovers almost nothing until\na late avalanche, so a bar tracking recovered blocks reads as frozen. It\ntracks collected symbols against the ~2x K actually needed — measured, not\nassumed.",[71,162,163,166],{},[29,164,165],{},"Interrupted transfers resume."," Blocks are written to disk as they are\nrecovered and the seen-symbol set is persisted to an append-only sidecar, so\na killed app does not restart from zero.",[71,168,169,172],{},[29,170,171],{},"Memory stays bounded."," Above a size threshold the un-peeled symbol pool\nspills to disk, which keeps a gigabyte-scale transfer from becoming an\nout-of-memory crash.",[10,174,176],{"id":175},"honest-limits","Honest limits",[15,178,179],{},"QR is roughly a 0.3 MB\u002Fs channel. A 1 GB file is about 9.5 hours of continuous\nscanning, and no amount of engineering changes that — so the sender now\nestimates the duration up front and asks for confirmation rather than letting\nyou discover it 90 minutes in. Porter is built for the tens-of-megabytes case,\nwhere it is genuinely the shortest path between two machines that cannot talk.",{"title":181,"searchDepth":182,"depth":182,"links":183},"",2,[184,185,186,187,188,189],{"id":12,"depth":182,"text":13},{"id":23,"depth":182,"text":24},{"id":62,"depth":182,"text":63},{"id":120,"depth":182,"text":121},{"id":145,"depth":182,"text":146},{"id":175,"depth":182,"text":176},null,"Air-gapped file transfer over dynamic QR codes. A terminal sender animates a file as QR frames; a Flutter app films the screen and rebuilds the bytes. No network, no cloud — the QR codes are the wire.","https:\u002F\u002Fporter.goranninkovic.com",false,"md",[196,199,202],{"value":197,"label":198},"160","tests across Rust and Dart, including cross-language parity",{"value":200,"label":201},"2","independent implementations of one bit-exact wire format",{"value":203,"label":204},"82x","faster fountain decode after fixing a quadratic endgame","\u002Fprojects\u002Fporter.webp","Rust",{},true,1.2,"\u002Fprojects\u002Fporter","https:\u002F\u002Fgithub.com\u002FTerrorSquad\u002Fporter",{"title":5,"description":191},[206,134,214,215,216,217,218],"axum","Dart","Flutter","LT fountain codes","GitHub Actions","projects\u002Fporter","Move files across an air gap with a camera",[222,206,216,223],"Systems","Error-Correcting Codes","rI0mCwpnkMrbBIVEyQqatpXPMAb5KSPm-Og_Z5li0yg",1786623948847]