<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bevan Stanely</title>
    <description>The latest articles on DEV Community by Bevan Stanely (@stan-ely).</description>
    <link>https://dev.to/stan-ely</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F663702%2Fd071d3a9-8d3b-4f7c-bd0b-ed703b688e0b.jpg</url>
      <title>DEV Community: Bevan Stanely</title>
      <link>https://dev.to/stan-ely</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stan-ely"/>
    <language>en</language>
    <item>
      <title>Sending a file with light: fountain codes and animated QR</title>
      <dc:creator>Bevan Stanely</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:38:36 +0000</pubDate>
      <link>https://dev.to/stan-ely/sending-a-file-with-light-fountain-codes-and-animated-qr-5ejo</link>
      <guid>https://dev.to/stan-ely/sending-a-file-with-light-fountain-codes-and-animated-qr-5ejo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://stan-ely.com/posts/2026/qrdrop-beam-fountain-codes/" rel="noopener noreferrer"&gt;stan-ely.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 8 October 2026.&lt;/strong&gt; The first version of this post said a numbered loop needs 8.3 × N frames at 10% loss. That figure modelled chunks picked at random, not the in-order loop described here. Rerun, the loop needs about 3.9 × N, against about 1.6 × N for the fountain code. The table, chart and text below are corrected, the script that produces them is now committed, and the table gains a column for losses that arrive in runs. Thanks to &lt;a href="https://mastodon.social/@VerdettoQR" rel="noopener noreferrer"&gt;@VerdettoQR&lt;/a&gt; on Mastodon, whose question prompted the rerun.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is a machine in a locked room with no network on it, and a file that has to get out. The realistic answer to that is a USB stick, which is why the interesting question isn't whether you can beat a USB stick — you mostly can't — but what the alternative has to look like before it's worth reaching for at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/stan-ely/qrdrop" rel="noopener noreferrer"&gt;qrdrop&lt;/a&gt; has a mode called &lt;strong&gt;beam&lt;/strong&gt; for this, shipped in 0.2.0 and current as of 0.6.0. The sender animates QR codes on its screen at roughly ten frames a second; the receiver points a camera at them. Nothing crosses a wire, no radio comes on, and there is no signalling of any kind between the two devices. It's the same package as the WebRTC transfer and almost none of the engineering is shared, because removing the &lt;em&gt;network&lt;/em&gt; removes something much more specific than bandwidth: it removes the back channel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdhfm0s9ae72i6y2qryer.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdhfm0s9ae72i6y2qryer.png" alt="Sequence diagram of beam mode. The sender screen emits about ten QR frames a second, roughly 600 bytes each, every one an LT-coded block, to the receiver camera. The manifest is woven in every 20 frames so a receiver can join mid-stream. There is no back channel at all: the sender cannot tell how the receiver is doing except by looking at it." width="800" height="673"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a one-way channel takes away
&lt;/h2&gt;

&lt;p&gt;Every transfer protocol you have ever used assumes it can ask a question. TCP asks "did you get that?" a few hundred times a second. HTTP asks "what encodings do you accept?" before it sends a byte.&lt;/p&gt;

&lt;p&gt;A screen and a camera cannot ask anything. The sender is never told which frames were missed, whether anyone is watching at all, or what the receiver is capable of decoding. The only feedback is a human looking at the receiving device and deciding to keep holding the phone up. So every decision below is downstream of one property: &lt;strong&gt;the sender picks blind, forever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is not a bandwidth problem, it's an information problem — and it changes the answer at four separate layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious design, and what a missed frame costs it
&lt;/h2&gt;

&lt;p&gt;The obvious design is to number the chunks and loop them. Chunk 1, chunk 2, … chunk N, back to chunk 1. &lt;code&gt;qrbeam&lt;/code&gt;, which is where the idea of adding this to qrdrop came from at all, does exactly this. It is correct, it is about fifty lines, and it is genuinely fine when nothing is dropped.&lt;/p&gt;

&lt;p&gt;The trouble is that things are dropped, and the failure is not proportional. To finish, the receiver needs &lt;em&gt;every&lt;/em&gt; one of N specific chunks — so once it has most of them, each new lap delivers mostly things it already has while it waits on the two or three it is still missing. It's a coupon-collector problem run in laps: every lap offers each missing chunk one more chance, and the transfer ends only when the unluckiest of N chunks has finally been seen, which takes about &lt;code&gt;ln(N) / ln(1/loss)&lt;/code&gt; laps. That grows with the file, and you start paying it the moment anything at all is lost. At N = 1,748 and ten frames a second, 10% loss turns a three-minute transfer into an eleven-minute one, because a missed frame doesn't cost you a frame, it costs you a full extra lap.&lt;/p&gt;

&lt;p&gt;Frames &lt;em&gt;are&lt;/em&gt; missed. jsQR needs 50–100 ms to decode one, so a phone browser manages about ten decodes a second against a display emitting exactly ten frames a second. There is no headroom in that. A hand that moves, a repaint caught mid-frame, an autofocus hunt — each one is a lap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Needing enough frames rather than particular ones
&lt;/h2&gt;

&lt;p&gt;The fix is a &lt;a href="https://en.wikipedia.org/wiki/Luby_transform_code" rel="noopener noreferrer"&gt;fountain code&lt;/a&gt;, specifically an LT code. Instead of carrying chunk &lt;em&gt;i&lt;/em&gt;, each frame carries the XOR of some pseudorandomly chosen set of chunks. Whenever a frame's set has exactly one unknown block left in it, that block falls out; XOR it away from every other frame that mentions it, and that may free another. This is peeling, and it runs until everything is solved or nothing more can be.&lt;/p&gt;

&lt;p&gt;The property that matters is not the cleverness. It's that a frame no longer has an identity worth waiting for: it doesn't matter &lt;em&gt;which&lt;/em&gt; frames arrived, only &lt;em&gt;how many&lt;/em&gt;. A dropped frame costs one frame, not a lap. That single change is what makes the transfer survivable over a channel with no way to ask for a retransmit — and it's the reason this is an 800-line codec instead of a chunk-and-cycle loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Frames the sender has to emit before the receiver has the complete file, over 1,748 blocks — which is a 1 MiB payload at this frame size, since 1,048,576 ÷ 600 rounds up to 1,748:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7cc2r952ghysvtcf199.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7cc2r952ghysvtcf199.png" alt="Grouped column chart, frames emitted before the file is complete, over 1,748 blocks of a 1 MiB payload, at four frame-loss rates, averaged over 20 seeds. A fountain code costs 1.05, 1.58, 2.06 and 2.79 times N at 0%, 10%, 30% and 50% loss; numbered chunks on a loop cost 1.00, 3.89, 7.02 and 11.22 times N over the same range. The loop's columns nearly quadruple between 0% and 10% and keep climbing, while the fountain code's stay short across the whole chart. The same numbers are in the table below." width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;frame loss&lt;/th&gt;
&lt;th&gt;this codec&lt;/th&gt;
&lt;th&gt;this codec, bursty&lt;/th&gt;
&lt;th&gt;numbered loop&lt;/th&gt;
&lt;th&gt;loop, bursty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.05×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.05×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.00×&lt;/td&gt;
&lt;td&gt;1.00×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.58×&lt;/strong&gt; (1.46–1.73)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.61×&lt;/strong&gt; (1.47–1.82)&lt;/td&gt;
&lt;td&gt;3.89× (2.90–5.87)&lt;/td&gt;
&lt;td&gt;3.12× (1.96–5.96)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.06×&lt;/strong&gt; (1.96–2.13)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.05×&lt;/strong&gt; (1.88–2.17)&lt;/td&gt;
&lt;td&gt;7.02× (5.41–10.14)&lt;/td&gt;
&lt;td&gt;6.01× (4.27–8.41)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.79×&lt;/strong&gt; (2.66–2.90)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.75×&lt;/strong&gt; (2.58–2.90)&lt;/td&gt;
&lt;td&gt;11.22× (8.70–14.52)&lt;/td&gt;
&lt;td&gt;11.08× (8.35–17.85)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How that was produced, since a table with no method attached is just a claim: the real codec encodes 1 MiB of seeded random bytes, a seeded PRNG decides whether each emitted frame reaches the decoder, the decoder is fed the survivors, and the count is frames emitted until it reports complete. Each cell is the mean of 20 seeds, with the lowest and highest in brackets. Seeded, because a flaky erasure-code benchmark is worse than none — it trains you to re-run until it passes, which is precisely how a real decoder stall gets waved through. The script is &lt;code&gt;beam-loss-bench.mjs&lt;/code&gt;, committed alongside this post and moving into the qrdrop repo so anyone can rerun it.&lt;/p&gt;

&lt;p&gt;"Independent" means every frame gets its own coin flip. A phone camera doesn't lose frames like that: it hands the decoder only the newest frame, so everything shown while the decoder is busy is lost together, in runs. The bursty columns model that, with runs averaging eight frames at the same overall loss rate. The fountain code doesn't notice, because it only counts arrivals. The loop doesn't get worse on average either, since a random run of lost chunks is simply re-offered next lap.&lt;/p&gt;

&lt;p&gt;What does break the loop is rhythm. A decoder that stalls on a fixed beat — one frame lost in every four, say — misses the same chunks on every lap whenever that beat divides N, and 1,748 is divisible by four. In that case the loop &lt;strong&gt;never finishes&lt;/strong&gt;, in all 20 runs, while the fountain code completes at 1.96 × N. Add a single frame of jitter to the beat and the loop recovers to 5.93 × N. Real decoders jitter, so this is a worst case rather than the normal one, but it is a failure mode a fountain code simply doesn't have.&lt;/p&gt;

&lt;p&gt;What the committed test suite pins is the 30% case, on a smaller payload, as a bound rather than a constant: &lt;code&gt;test/beam.test.mjs&lt;/code&gt; fails if the emitted count is not under half of &lt;code&gt;N·ln(N)&lt;/code&gt;. That is deliberately a loose regression guard on the &lt;em&gt;shape&lt;/em&gt; — and &lt;code&gt;N·ln(N)&lt;/code&gt; is the cost of chunks picked at random, not of the loop above, which is the confusion that put 8.3 × N in the first version of this post.&lt;/p&gt;

&lt;p&gt;Read the shape rather than the constants in any case. The loop doesn't degrade gently: it goes from 1.00 × N to nearly 3.9 × N between 0% and 10% loss, because it starts paying for the unluckiest chunk as soon as there is one. The fountain stays inside a small factor across the whole range.&lt;/p&gt;

&lt;h2&gt;
  
  
  The systematic prefix, which is a trade and not a win
&lt;/h2&gt;

&lt;p&gt;Here is the one real departure from the closest prior art, and it costs something.&lt;/p&gt;

&lt;p&gt;A pure fountain pays its overhead even when nothing goes wrong: you always need somewhat more than N frames to recover N blocks, because some arrive carrying nothing you can peel yet. For small N and a robust soliton distribution that overhead is genuinely bad — 1.3–1.5× for a handful of blocks.&lt;/p&gt;

&lt;p&gt;So the first N frames are sent &lt;strong&gt;systematically&lt;/strong&gt;: block 0 plain, block 1 plain, and only then does the fountain start. A clean capture therefore costs exactly N frames and nothing more, which is the common case on a decent camera. Nothing downstream needs to know about the special case — a systematic frame is just a degree-1 frame whose single index the decoder derives the same way it derives every other set.&lt;/p&gt;

&lt;p&gt;The cost is real and shows up under loss. With a systematic prefix the decoder needs about &lt;strong&gt;1.35 distinct frames per block&lt;/strong&gt;, against the &lt;strong&gt;~1.13&lt;/strong&gt; a pure LT code reaches (both measured by the same script, 20 seeds at 10–50% loss). By the time the fountain starts, most blocks are already solved, so a degree-&lt;em&gt;d&lt;/em&gt; frame drawn against all N carries far fewer &lt;em&gt;unknowns&lt;/em&gt; than its degree suggests. You are paying for degree you can't use.&lt;/p&gt;

&lt;p&gt;Which side of that trade is right depends entirely on how good you expect the camera to be, and this bets on it being good. That's a bet, not a proof, and the number that would falsify it sits in the README next to the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frame is a line of text
&lt;/h2&gt;

&lt;p&gt;A beam frame is a QR code, and a QR code is a string, so the wire format is pipe-delimited text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QRD1|D|&amp;lt;session&amp;gt;|&amp;lt;seed&amp;gt;|&amp;lt;base64url payload&amp;gt;|&amp;lt;crc32&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sizing runs backwards from the QR. A version-20 code at error correction level L holds 858 bytes in byte mode, the header costs about 50 characters, and base64url inflates the payload by a third. 600 raw bytes lands just inside that, and keeps every frame at a module count a phone can lock onto from across a desk. Raising it pushes the QR version up, and a denser code is one that takes three attempts to scan — which costs far more than the bytes gained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The seed is the interesting field.&lt;/strong&gt; A frame is the XOR of a set of blocks and the decoder has to know which set. The obvious encoding is a list of indices, but a degree-40 frame would then need forty numbers in its header — at 600 bytes a frame, a meaningful fraction of the payload. Instead the frame carries an eight-character seed, and both halves run the same PRNG, splitmix32, to regenerate the set. Eight characters whatever the degree.&lt;/p&gt;

&lt;p&gt;That makes the generator part of the wire format. Changing splitmix32 breaks compatibility exactly as surely as renaming a field would, which is why it's pinned in the codec rather than reached for from somewhere general, and why the protocol tag &lt;code&gt;QRD1&lt;/code&gt; exists to be bumped the day a decoder of the previous version would misread a frame.&lt;/p&gt;

&lt;p&gt;The degree comes from a robust soliton distribution, precomputed once per transfer as a CDF (&lt;code&gt;c = 0.03&lt;/code&gt;, &lt;code&gt;delta = 0.05&lt;/code&gt;, the standard constants for N in the hundreds-to-thousands). The &lt;em&gt;ideal&lt;/em&gt; soliton distribution has exactly the right expected behaviour and is useless in practice: it expects precisely one peelable frame at every step, and peeling stalls the moment variance takes that away. The robust version adds a spike of extra low-degree frames to keep the ripple populated, for a few percent more frames overall.&lt;/p&gt;

&lt;h2&gt;
  
  
  CRC32, and why it is not a hash
&lt;/h2&gt;

&lt;p&gt;Every frame ends in eight hex characters of CRC32, and that is not a security control.&lt;/p&gt;

&lt;p&gt;What it defends against is a QR decoder handing back a plausible-looking string it got slightly wrong — a real failure mode when a camera catches a frame mid-repaint. One corrupt block XORed into the peeling state silently poisons every block that depends on it, and the damage surfaces minutes later as a SHA-256 mismatch on the whole file with nothing to point at. CRC32 catches that at the door for eight characters.&lt;/p&gt;

&lt;p&gt;It is not a forgery defence and nothing pretends it is. An attacker who can put frames on the screen the receiver is pointed at has already won, by definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manifest, woven in
&lt;/h2&gt;

&lt;p&gt;The receiver can do nothing with a data frame until it knows N and the block size — and it cannot be assumed to have been pointed at the screen when the sender started. So the manifest isn't sent once at the front; it's interleaved into the cycle every 20 frames.&lt;/p&gt;

&lt;p&gt;Sending it once would be free, and would strand anyone who didn't catch the first two seconds of the loop. Every 20 frames means a receiver joining at any moment waits about two seconds at most, for roughly a 5% cost in frames. In a mode where the receiver is a person picking up a phone, that is not a close call.&lt;/p&gt;

&lt;h2&gt;
  
  
  gzip, specifically not brotli
&lt;/h2&gt;

&lt;p&gt;Payloads are gzipped first, and the compressed result is kept only if it actually shrank. Text, CSV, JSON and source typically compress 3–10×; a 900 KB JPEG doesn't shrink and is refused against the cap.&lt;/p&gt;

&lt;p&gt;Brotli is the better algorithm — 15–20% smaller on text at this size — and no longer a dependency question either, since the WHATWG Compression Standard now lists it as a &lt;code&gt;CompressionStream&lt;/code&gt; format. It is still the wrong choice &lt;strong&gt;here&lt;/strong&gt;, for a reason particular to this mode: a one-way channel cannot negotiate. The sender picks blind, and the receiver either can inflate it or cannot.&lt;/p&gt;

&lt;p&gt;gzip has been baseline in every browser since May 2023. Brotli is Safari 18.4+ and Firefox 147+, with Chrome — the likely sender and a very common receiver — behind. A sender choosing brotli hands some phones a file they structurally cannot open, and the only remedy the UI could offer is "try a different browser". Twenty-five seconds off a three-minute transfer is a bad trade when the failure mode is total.&lt;/p&gt;

&lt;p&gt;This is why the manifest's &lt;code&gt;encoding&lt;/code&gt; field is a string rather than a boolean. When Chrome ships brotli, preferring it is a feature-detect and one string.&lt;/p&gt;

&lt;p&gt;There is a guard on the other side of it, too: a manifest may not declare an original size above 64 MiB, and the inflate is stopped the moment it exceeds the size the manifest declared. The manifest arrives from the peer exactly as the filename does, and this codebase treats that as hostile input — a decompression bomb being the obvious attack on "we will inflate whatever you tell us to".&lt;/p&gt;

&lt;h2&gt;
  
  
  Roughly 6 kB/s, and a cap that is admitted rather than hidden
&lt;/h2&gt;

&lt;p&gt;Beam runs at about &lt;strong&gt;6 kB/s&lt;/strong&gt;, with a &lt;strong&gt;1 MiB cap applied after compression&lt;/strong&gt;. At that rate the cap is already a three-and-a-half minute transfer, so it's a limit on patience as much as on anything technical.&lt;/p&gt;

&lt;p&gt;Minutes is also long enough to meet a failure that has nothing to do with coding theory. A beam is one device showing a code to another device's camera with nobody touching either screen, which is exactly what a display timeout counts — so until 0.5.0 the code being read could go dark part-way through. The display is now held awake from the moment a transfer starts until it ends, and taken back after a glance at a notification, which the platform would otherwise not return.&lt;/p&gt;

&lt;p&gt;The second reason is structural: &lt;strong&gt;a fountain code has no ordering.&lt;/strong&gt; The decoder cannot write anything to disk until peeling completes, because any block may turn out to be needed to solve any other, so every block is held in memory until the last one arrives. That is the bill for not needing particular frames.&lt;/p&gt;

&lt;p&gt;Raising the cap would want fountain-coding independent ~256 KiB windows, so memory stays bounded and each window can be flushed as it solves. &lt;strong&gt;That is not built.&lt;/strong&gt; It roughly doubles the protocol's moving parts to raise a cap that a 6 kB/s transfer rate makes academic — written down as a known limitation rather than left for someone to discover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beam is not encrypted, and it cannot be
&lt;/h2&gt;

&lt;p&gt;This needs its own section rather than a footnote, because everything else qrdrop publishes about confidentiality describes the WebRTC path and stops at this mode's edge.&lt;/p&gt;

&lt;p&gt;There is no handshake here, so there is no ECDH, no forward secrecy, and no short-authentication-string check. There is no peer to authenticate — only photons. The only adversary that exists in this mode is someone who can see the screen, against whom a key displayed on that same screen is worth nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6mdkvrti1tw3fw9rlj6p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6mdkvrti1tw3fw9rlj6p.png" alt="The beam screen: a banner reading " width="800" height="955"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both beam screens say so, the README carves the mode out of the threat model explicitly rather than letting the document's other claims appear to cover it, and the changelog entry that shipped it led with the limitation. Beam is offered as an alternative to a USB stick on an air-gapped machine, not as a private channel — and a mode that &lt;em&gt;looked&lt;/em&gt; as protected as the WebRTC path while not being so would be worse than having no offline mode at all.&lt;/p&gt;

&lt;p&gt;For the same reason there is no &lt;code&gt;qrdrop beam&lt;/code&gt; command and there will not be one: the mode needs a screen to animate and a camera watching it, and a terminal on the receiving end has neither.&lt;/p&gt;

&lt;p&gt;The desktop app wraps the same page, so beam is there too — and on Linux, where WebKitGTK ships no &lt;code&gt;RTCPeerConnection&lt;/code&gt;, it is the only way the app can move a file at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prior art, and what is actually mine
&lt;/h2&gt;

&lt;p&gt;Neither the idea nor the design is original here, and it would be tidier but dishonest to present them that way.&lt;/p&gt;

&lt;p&gt;The prompt was &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/qrbeam" rel="noopener noreferrer"&gt;qrbeam&lt;/a&gt;&lt;/strong&gt;, which sends a file offline as animated QR codes to an iOS receiver. That is where the idea of adding this to qrdrop came from at all. Its wire format numbers the chunks and loops them, which is what the table above compares against — named, because a benchmark against an unnamed strawman is worth less to a reader and unfair to the party being measured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/divan/txqr" rel="noopener noreferrer"&gt;txqr&lt;/a&gt;&lt;/strong&gt; by Ivan Daniluk got to the fountain code first, and is the closest prior art to what's built here: animated QR frames carrying LT-coded blocks, so the receiver needs &lt;em&gt;enough&lt;/em&gt; frames rather than &lt;em&gt;particular&lt;/em&gt; ones. Daniluk's &lt;a href="https://divan.dev/posts/fountaincodes/" rel="noopener noreferrer"&gt;write-up on fountain codes and animated QR&lt;/a&gt; is the better explanation of why LT codes suit this problem at all, and is worth reading before this post. The reasoning above was arrived at independently, which makes it &lt;strong&gt;convergent rather than novel&lt;/strong&gt; — no code was taken from either project.&lt;/p&gt;

&lt;p&gt;What differs is small, and worth stating plainly rather than dressing up: the first N frames are systematic, compression is decided by measurement rather than assumption, the manifest is interleaved so a receiver can join mid-stream, and both halves run in a browser with no install on either side.&lt;/p&gt;

&lt;p&gt;That last one is the part I'd defend. The coding theory here is decades old and better explained elsewhere. What's mine is the set of trades made against one specific one-way channel, each with the number that would have changed the answer written down beside it.&lt;/p&gt;

&lt;p&gt;Open &lt;a href="https://share.stan-ely.com/" rel="noopener noreferrer"&gt;share.stan-ely.com&lt;/a&gt; on your laptop and point your phone's camera at it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>algorithms</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>qrdrop: what 'no server' forces you to build</title>
      <dc:creator>Bevan Stanely</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:21:25 +0000</pubDate>
      <link>https://dev.to/stan-ely/qrdrop-what-no-server-forces-you-to-build-4nk3</link>
      <guid>https://dev.to/stan-ely/qrdrop-what-no-server-forces-you-to-build-4nk3</guid>
      <description>&lt;p&gt;"No server" sounds like a feature you'd put in a bullet list. It isn't one. It's a constraint, and constraints don't hand you good decisions — they just remove the bad ones that would otherwise have been easiest. &lt;a href="https://github.com/stan-ely/qrdrop" rel="noopener noreferrer"&gt;qrdrop&lt;/a&gt; is a file-transfer tool (&lt;code&gt;npm i -g qrdrop&lt;/code&gt;, or just try it at &lt;a href="https://share.stan-ely.com" rel="noopener noreferrer"&gt;share.stan-ely.com&lt;/a&gt;) built around exactly one rule: nothing in between the two devices ever holds a readable copy of the file. There's no account, no upload, no backend that could see the bytes even if it wanted to. Everything below is what that rule cost, in the order the costs showed up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://stan-ely.com/posts/2026/qrdrop-no-server/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F89bxjruy60cmgmaspf5o.webp" alt="One real qrdrop transfer, filmed from both ends. On a laptop, report.pdf is dropped onto share.stan-ely.com and a QR code appears. A phone's camera scans the code. Both screens then show the same four symbols — taco, umbrella, snake, rainbow. The laptop sends the file and reports it delivered while the phone opens the received report.pdf." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One real transfer, filmed from both ends — not staged, not sped up. ▶ &lt;a href="https://stan-ely.com/posts/2026/qrdrop-no-server/" rel="noopener noreferrer"&gt;Watch the 12-second recording on the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No account, so the QR has to be the entire credential
&lt;/h2&gt;

&lt;p&gt;Start from the constraint: there's no server to register a device with, so there's nothing to log into. Two devices in the same room have to recognize each other using only what passes between them, and the one channel a network attacker can't stand in the middle of is a physical QR scan — a phone's camera pointed at a laptop screen.&lt;/p&gt;

&lt;p&gt;So the QR has to carry a credential, not a pointer to one. qrdrop's QR is 32 bytes of CSPRNG output, full stop. Everything the protocol needs — the room the peers meet on, the password that encrypts their signalling, the session keys, even the words they'll read aloud to each other — is derived from those 32 bytes with HKDF. There's no identity behind the code, no account it belongs to. That's also the honest limit of what it protects: &lt;strong&gt;anyone with the code can join.&lt;/strong&gt; It's the entire credential. Show the QR to a person, not to a room.&lt;/p&gt;

&lt;h2&gt;
  
  
  The room ID can't be the secret — a public relay is watching
&lt;/h2&gt;

&lt;p&gt;The obvious next move, once you have a 32-byte secret, is to use it directly as the room name two peers meet on. It's the shortcut a first draft reaches for, and it works — the two peers show up in the same place, first try, every time you test it.&lt;/p&gt;

&lt;p&gt;It's also wrong, because "no server" means signalling has to happen somewhere public. qrdrop's peers announce over Nostr relays and WebTorrent trackers — networks neither peer controls and neither trusts — and a room name on a public relay is not private. If the room ID &lt;em&gt;were&lt;/em&gt; the secret, every relay operator watching announcements would have your decryption key the moment pairing started, for free, before a single file byte moved.&lt;/p&gt;

&lt;p&gt;The fix is that the room ID is &lt;em&gt;derived&lt;/em&gt; from the secret (&lt;code&gt;HKDF(secret, "topic")&lt;/code&gt;), never the secret itself. A relay sees a room name it can't invert back to the QR contents. This is the kind of bug that a demo doesn't catch — the transfer completes, the file arrives, everything looks fine — because publishing the key to a room name doesn't fail the transfer, it just fails the promise you made about it in the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  No server to vouch for either side, so a human has to
&lt;/h2&gt;

&lt;p&gt;A server, if there were one, could do identity checking for you — you'd log in, and the server would tell each side who the other one was. There isn't one here, so nothing can attest that the peer on the other end of the WebRTC data channel is actually the phone that scanned the code, rather than something between them. WebRTC signalling gets a password derived from the secret too (&lt;code&gt;HKDF(secret, "signal")&lt;/code&gt;), which stops an observer from substituting their own session description in transit — the textbook man-in-the-middle on WebRTC signalling, and the specific thing the QR exists to prevent. But that's defense against a network attacker forging the connection; it isn't proof the two humans looking at their screens agree on what got connected.&lt;/p&gt;

&lt;p&gt;For that, qrdrop falls back to the oldest trust anchor there is: a person. Once the data channel is up, both screens derive and display four emoji — the same four, if the pairing is genuine — and a human reads them aloud and matches them by eye. The words under the emoji are what a screen reader speaks, because those are the words a person actually says out loud to their peer; the emoji tiles are decoration on top of that.&lt;/p&gt;

&lt;p&gt;The sender confirms this SAS &lt;em&gt;before&lt;/em&gt; the file manifest goes out, because the manifest alone discloses filename and size to whoever's on the other end of that channel. This is the gesture the project treats as non-negotiable: &lt;code&gt;--yes&lt;/code&gt; on the CLI skips the receiver's accept prompt, and cannot skip the SAS confirmation. A flag that skipped it would be a vulnerability wearing convenience's clothes — the SAS is the whole defense against a successful man-in-the-middle, and there's no server anywhere in this picture that could do that job instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bytes cross a relay you don't control, so they're sealed twice
&lt;/h2&gt;

&lt;p&gt;Direct peer-to-peer doesn't always work out. When NAT traversal fails, WebRTC falls back to a TURN relay — in qrdrop's case, the Open Relay Project's public, shared, metered infrastructure, because there's no server of its own to run one. That relay sits between the two devices and forwards every packet. It should not be able to read any of them.&lt;/p&gt;

&lt;p&gt;DTLS already encrypts the WebRTC data channel itself, terminating at each peer's browser — but "no server" also means qrdrop can't fully trust the signalling layer underneath that connection, so the file bytes get sealed a second time, independently, with an ephemeral ECDH key exchanged directly between the two peers and used to derive AES-GCM keys per direction. Two layers that fail independently: if Trystero's signalling encryption were ever broken outright, an attacker still needs the QR secret to derive the session key, because the ECDH uses that secret as its HKDF salt. A TURN relay operator, in the worst case, sees ciphertext and byte counts. Never contents.&lt;/p&gt;

&lt;p&gt;The load-bearing word in all of that is &lt;em&gt;ephemeral&lt;/em&gt; — a keypair generated fresh, per session, and thrown away with the room, which is what gives a leaked code no power over a transfer that already finished. It rests on one line of code: the keypair is created inside &lt;code&gt;joinVia&lt;/code&gt;, per call, never hoisted to module scope. Hoisting it looks like an obvious optimization — one keypair generation instead of two, since &lt;code&gt;openRoom&lt;/code&gt; races two signalling strategies and only needs one to win — and it would silently turn every transfer a running process ever makes into one long session with forward secrecy quietly gone. &lt;code&gt;test/room.test.mjs&lt;/code&gt; pairs twice over the same secret against a fake in-memory strategy and asserts the second session can't decrypt the first's traffic, specifically so that regression fails in two seconds locally rather than surviving to a release.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikmpuyskuiye3yure0fn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikmpuyskuiye3yure0fn.png" alt="Sequence diagram of the WebRTC path. The sender generates 32 random bytes and shows them as a QR code, which the receiver scans; the room ID, signalling password, session keys and the SAS all derive from those bytes. Both peers announce on every signalling network at once, exchange session descriptions encrypted under the derived password, and open a WebRTC data channel. An ephemeral ECDH gives one key per direction; four emoji are read aloud and matched by hand; only then does the manifest go out, the receiver accepts, and the file chunks follow, each one sealed." width="800" height="863"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The WebRTC path — room ID, signalling password, session keys, and the SAS all derive from the 32 bytes in the QR.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No network at all means no transfer — unless the screen is the network
&lt;/h2&gt;

&lt;p&gt;Every decision above assumes a network exists, even a hostile, public, unreliable one. Take that away — an air-gapped machine — and there's genuinely nothing left: no relay to announce on, no TURN to fall back to, no data channel to open. The constraint doesn't relax here; it just changes shape. If there's no network, the only channel left is the screen itself.&lt;/p&gt;

&lt;p&gt;That's what &lt;strong&gt;Beam&lt;/strong&gt; is: the sender animates QR codes on screen at roughly ten frames a second, and the receiver's camera reads them, with no wire, no radio, and no signalling of any kind between the two devices. The payload is &lt;a href="https://en.wikipedia.org/wiki/Luby_transform_code" rel="noopener noreferrer"&gt;fountain-coded&lt;/a&gt; rather than a numbered loop, because a numbered loop turns a missed frame into a coupon-collector problem — waiting for the &lt;em&gt;specific&lt;/em&gt; frame you missed, out of a loop you have to sit through again. A fountain frame doesn't care which frames were missed, only how many arrived, which is why the difference between the two grows sharply as frame loss climbs (measured over 1,748 blocks of a 1 MiB payload): 1.05× N frames needed at 0% loss versus 1.00× N for a numbered loop, but 2.08× N versus 10.7× N at 30% loss.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flee9r58c07if1od5enw3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flee9r58c07if1od5enw3.png" alt="Sequence diagram of beam mode. The sender screen emits about ten QR frames a second, roughly 600 bytes each, every one an LT-coded block, to the receiver camera. The manifest is woven in every 20 frames so a receiver can join mid-stream. There is no back channel at all: the sender cannot tell how the receiver is doing except by looking at it." width="800" height="673"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Beam mode — the sender's screen emits ~10 LT-coded QR frames a second; the receiver's camera reads them. No back channel.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And here is where the constraint stops being convenient and just states its own limit plainly: &lt;strong&gt;Beam is not encrypted, and it cannot be.&lt;/strong&gt; No network means no handshake, which means no key agreement, no forward secrecy, and no SAS — there's no peer to authenticate over a channel of light, only photons, and a key shown on the same screen as the payload protects nothing. Both beam screens say so directly. It's offered as the alternative to a USB stick on an air-gapped machine, not as a private channel, and the project is explicit that everything it claims about confidentiality elsewhere in the document stops at Beam's edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint is what made it worth building
&lt;/h2&gt;

&lt;p&gt;None of this reads like a feature list, and that's the point. A server would have made every one of these problems disappear — an account instead of a 32-byte credential, a private room name instead of a derived one, a trusted third party instead of four emoji read aloud, TLS to a backend instead of a second encryption layer over a public relay. Removing the server didn't remove the problems; it just moved every one of them onto the two devices and the person standing between them, which is exactly where a network attacker can't reach the contents.&lt;/p&gt;

&lt;p&gt;The honest caveats stay attached rather than getting rounded off: TURN is free, shared, and capped at 100 MB for exactly that reason; Firefox and Safari buffer whole files in memory before saving, so practical transfers there cap out well short of what Chromium can stream to disk; the page serving the code could itself be compromised, and no in-browser design stops that — it's mitigated by a strict CSP and shipped source maps so the claims can be checked against what's actually running, not eliminated. None of that is dressed up, because the constraint that produced the interesting decisions is the same one that produces the limits.&lt;/p&gt;

&lt;p&gt;That distinction is not academic. A report came in from the field — &lt;code&gt;Out-of-order frame: expected 0, got 13877&lt;/code&gt;, on a transfer under a megabyte — and behind it was a stranger in the rendezvous room ending a live transfer with fourteen bytes of well-formed cleartext. Frame headers were being read for type and sequence &lt;em&gt;before&lt;/em&gt; the AES-GCM tag was checked, and inbound frames weren't filtered to the paired peer, so one packet from someone who held no key and never paired was enough to make the receiver abort and throw away the partial file. Nothing was readable, forgeable or alterable; what was lost was availability, which is still a real thing to lose. Frames are authenticated before their headers are trusted now, and one that fails its tag is dropped and counted rather than treated as a fault in the transfer.&lt;/p&gt;

&lt;p&gt;The part worth keeping isn't the fix. It's that the report was legible as a bug at all. A stray packet only reads as an attack if the document already says who was supposed to be able to send one.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/stan-ely" rel="noopener noreferrer"&gt;
        stan-ely
      &lt;/a&gt; / &lt;a href="https://github.com/stan-ely/qrdrop" rel="noopener noreferrer"&gt;
        qrdrop
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Send a file straight from one device to another. A QR code carries the key; the file goes over WebRTC; nothing in between ever holds a readable copy.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;qrdrop&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;
  Send a file straight from one device to another.&lt;br&gt;
  A QR code carries the key; the file goes over WebRTC;&lt;br&gt;
  nothing in between ever holds a readable copy
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://www.npmjs.com/package/qrdrop" rel="nofollow noopener noreferrer"&gt;&lt;img alt="npm" src="https://camo.githubusercontent.com/a16dd8b94bb8af82cd83cc4bf4af0cc05b2f91a699ceb4b77f6abf92836587b1/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f717264726f70"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/stan-ely/qrdrop/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img alt="ci" src="https://github.com/stan-ely/qrdrop/actions/workflows/ci.yml/badge.svg"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/795841e33778fb26b9ecee9a5ea8e1f279c35d8c7c3da01188f4c944c463d2f0/68747470733a2f2f696d672e736869656c64732e696f2f6e6f64652f762f717264726f70"&gt;&lt;img alt="node" src="https://camo.githubusercontent.com/795841e33778fb26b9ecee9a5ea8e1f279c35d8c7c3da01188f4c944c463d2f0/68747470733a2f2f696d672e736869656c64732e696f2f6e6f64652f762f717264726f70"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/stan-ely/qrdrop#licence" rel="noopener noreferrer"&gt;&lt;img alt="licence" src="https://camo.githubusercontent.com/a1dac2b26a927cec02d9d40ccd1a34a41e272ec66f5d61162e471ca677b8133a/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f6c2f717264726f70"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;b&gt;&lt;a href="https://share.stan-ely.com" rel="nofollow noopener noreferrer"&gt;share.stan-ely.com&lt;/a&gt;&lt;/b&gt; — no install, no account, no upload
&lt;/p&gt;

&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/stan-ely/qrdrop/docs/loop.gif"&gt;&lt;img width="900" alt="One real qrdrop transfer, filmed from both ends. On a laptop, report.pdf is dropped onto share.stan-ely.com and a QR code appears. A phone's camera scans the code. Both screens then show the same four symbols — taco, umbrella, snake, rainbow — under “Check both devices show the same symbols”. The laptop sends the file and reports it delivered while the phone opens the received report.pdf. The clip loops through a brief dip to black." src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fstan-ely%2Fqrdrop%2FHEAD%2Fdocs%2Floop.gif"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npx qrdrop send report.pdf     &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; prints a QR code in your terminal&lt;/span&gt;
npx qrdrop receive             &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; on the other machine&lt;/span&gt;
npx qrdrop web                 &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or run the browser UI locally, from this copy&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;npx&lt;/code&gt; stays the headline because it installs nothing. If you would rather have it
on &lt;code&gt;PATH&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;brew install stan-ely/tap/qrdrop                                   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS, Linux&lt;/span&gt;
scoop bucket add stan-ely https://github.com/stan-ely/scoop-bucket  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows&lt;/span&gt;
scoop install stan-ely/qrdrop
npm i -g qrdrop                                                    &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; anywhere, Node 22+&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;A file sent from the CLI can be received in a browser, and the other way round.
That interoperability is the reason this is one package rather than three.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Browser&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://share.stan-ely.com" rel="nofollow noopener noreferrer"&gt;share.stan-ely.com&lt;/a&gt;, or &lt;code&gt;&amp;lt;qr-drop&amp;gt;&lt;/code&gt; in a page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/stan-ely/qrdrop" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;If you want to see whether it holds up: open &lt;a href="https://share.stan-ely.com" rel="noopener noreferrer"&gt;share.stan-ely.com&lt;/a&gt; on a laptop, drop a file onto it, and point a phone's camera at the code that appears. Watch the four words. Then check that they matched.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>security</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>cuda-gdb error with libtinfo.so.5</title>
      <dc:creator>Bevan Stanely</dc:creator>
      <pubDate>Thu, 08 Jul 2021 14:50:33 +0000</pubDate>
      <link>https://dev.to/stan-ely/cuda-gdb-error-with-libtinfo-so-5-289i</link>
      <guid>https://dev.to/stan-ely/cuda-gdb-error-with-libtinfo-so-5-289i</guid>
      <description>&lt;p&gt;Originally Published on &lt;a href="https://bevs.xyz/blog/2021/07/cuda-gdb-libtinfo.so.5/" rel="noopener noreferrer"&gt;bevs.xyz&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I had a fresh install of cuda-tools from the Manjaro software repository and I simply couldn’t run &lt;strong&gt;cuda-gdb&lt;/strong&gt;. I was getting the following error&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cuda-gdb: error &lt;span class="k"&gt;while &lt;/span&gt;loading shared libraries: libtinfo.so.5: cannot open shared object file: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A related error was as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cuda-gdb: error &lt;span class="k"&gt;while &lt;/span&gt;loading shared libraries: libncursesw.so.5: cannot open shared object file: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had to investigate the files &lt;code&gt;libtinfo.so.5&lt;/code&gt; and &lt;code&gt;libncursesw.so.5&lt;/code&gt;. It was a simple version-conflict with ncurses. My distro had &lt;strong&gt;ncurses 6.2&lt;/strong&gt;, whereas cuda-gdb was insisting on &lt;strong&gt;ncurses 5&lt;/strong&gt;. A simple symlink was in order, actually two. The following two lines saved my day!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /usr/lib/libncursesw.so.6.2  /usr/lib/libtinfo.so.5

&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /usr/lib/libncursesw.so.6.2  /usr/lib/libncursesw.so.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got the necessary pointers from this &lt;a href="https://stackoverflow.com/a/42686791/16394350" rel="noopener noreferrer"&gt;stackoverflow answer&lt;/a&gt; for an unrelated Android Studio question.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>cuda</category>
      <category>troubleshooting</category>
      <category>manjaro</category>
    </item>
  </channel>
</rss>
