# Changelog Notable changes, newest first. Dates are the release date; the commit history is the finer-grained record. ## Unreleased **Ready for codes that work without the internet.** A pairing code may now carry a short suffix after its key: where the other device can be reached on the local network. Nothing makes such a code yet. This release teaches every place that reads a code to accept one, so that when a later version starts printing them, the version you have still pairs with it, over the internet as before. **Send text, not just files.** "Send text" on the first screen opens a box to type or paste into, and pasting text anywhere on that screen opens the same box with it filled in. The pasted text isn't sent until you press Send. You both check the same four symbols as for a file, and the other device accepts it the same way. Instead of saving it, the receiving page then shows the text with **Copy** and **Save as file**. A message can be up to 64 KB. Nothing about the protocol changed: the text travels as a small `message.txt`, so the command line and older versions receive it as that file. **The command line is now a single native binary.** `npx qrdrop`, `npm i -g qrdrop`, `brew install stan-ely/tap/qrdrop` and `scoop install stan-ely/qrdrop` all install the same commands, flags and output as before, now built in Rust. It is one self-contained file, about 11 MB on Windows, with nothing else to install: the Homebrew formula and the Scoop manifest no longer pull in Node, and the binary for each platform is also attached to the GitHub Release. Through npm, the binary arrives as a package matched to your platform (`qrdrop-linux-x64`, `qrdrop-darwin-arm64`, and so on). On a platform with no binary, or with optional dependencies turned off, the `qrdrop` command runs the JavaScript version exactly as it always has. `QRDROP_CLI=js` picks the JavaScript one on purpose. Both speak the same protocol, and transfers between them were tested in every pairing. One visible difference: an unknown option is a usage error that exits 2, where the JavaScript version printed a stack trace and exited 1. For code using the package directly, nothing changes. `qrdrop`, `qrdrop/web` and `qrdrop/node` export what they did. **Several times faster between two native devices on one network.** When both ends run the command line or the qrdrop app, and one of them can accept a connection without the operating system asking its person first, the file itself now moves over a direct connection on the local network instead of WebRTC. Measured between two copies of the command line, 256 MB went at 33–42 MB/s against 17 MB/s. The file is encrypted exactly as before, the connection is offered only after the four symbols are confirmed, and nothing about pairing changes. Linux listens; Windows and macOS never do, because that is where a firewall dialog would appear, but they still connect out to a device that listens, such as a phone running the app or a Linux machine. Two Windows machines stay on WebRTC. `--no-lan` turns it off. An older version on the other end ignores the offer, and the transfer goes over WebRTC as it always has. For code using the package directly: `sendFile` takes an optional `onAccept`, run after the receiver accepts and before the first chunk; `createReceiver` takes an optional `onPeerLan`; and a paired room has `attachLan`, `sendOverLan` and `onClose`. The negotiation is `createLanLink` in `src/transport/lan.js`, and its `onCarrying` tells a receiver when the first frame arrives over the direct connection. **Three to ten times faster across the internet between two command lines.** When both ends run the native command line, the file now moves over a direct QUIC connection instead of WebRTC, wherever the two can reach each other directly (one network, or two home routers a connection can be punched through, as WebRTC does), and the sender prints `Using a direct QUIC connection.` Measured on an emulated link, 64 MB went at 51 MB/s against 13 at 50 ms, and at 32 against 8.5 at 100 ms; with 0.1% loss behind a 100 Mbit/s bottleneck it was 9 against 0.9 at 50 ms. As with the local-network path, the connection is offered only after the four symbols are confirmed, each end accepts only the certificate the other named over the encrypted session, and the file is encrypted inside it exactly as before. When the connection cannot be made the sender waits three seconds at most and carries on over WebRTC; a browser, the JavaScript command line or the app on the other end never answers the offer, and costs no wait at all. `QRDROP_QUIC=0` turns it off, and so does `--no-lan`. **WebRTC between two native ends is faster too.** The native command line now asks for 4 MB socket buffers, without which its bursts overran the operating system's default and the connection spent its time recovering from loss: 25 MB/s against 8.4 on a fast link, and 8.5 against 1.2 at 100 ms. A browser on the other end was already the limit, and still is. **Dependencies.** `node-datachannel` moves from 0.33.0 to 0.33.4. It was pinned because 0.33.0 was the last version to ship every platform's prebuilt binary, and 0.33.4 ships them all again, adding Windows on ARM. The Trystero packages go to 0.25.4, a patch with no API change. Both interop suites and the two-browser suite pass on the new versions. **One signalling relay replaced.** `wss://relay.mostr.pub` now answers every connection with a permanent redirect to `relay.ditto.pub`, and a WebSocket does not follow redirects, so it had stopped carrying any pairing. The list names `wss://relay.ditto.pub` in its place, which paired two peers on every attempt, and the app's content security policy follows. **Pairing works again after the network has been away for a while.** If every signalling relay was unreachable for about two minutes, the signalling library stopped trying them for good, and every pairing after that timed out until the page was reloaded or the command restarted. It happened when the operating system cut off a page in the background, which Android does to a backgrounded browser or app within seconds, and a browser's "offline" signal never fired for it. Relays that drop now wait and reconnect as soon as a pairing needs them. Measured with every relay refused for two and a half minutes: the next pairing timed out after 60 seconds before this change, and now pairs in about three. **The progress bar redraws at most ten times a second.** It redrew the whole component after every 16 KB frame, which on a phone was a measurable share of the time spent per frame; see the app changelog for the numbers. The final 100% is always drawn. **Beam's loss numbers are measured, and one was wrong.** Since 0.2.0 the docs, the README and this changelog have said a numbered loop needs 8.3×N frames at 10% loss (10.7×N at 30%). That is what chunks picked at random cost, not an in-order loop like qrbeam's, which measures 3.89×N at 10% and 7.02×N at 30%. The fountain code's own figures move slightly too, to 1.58×N and 2.06×N as the mean of 20 seeds. `node scripts/bench-beam.mjs` now produces every one of these numbers, including losses that arrive in runs and the decoder overhead the systematic prefix costs (about 1.35 distinct frames per block, against 1.13 for a pure LT code). The codec itself is unchanged. ## 0.6.0 — 2026-09-18 **A transfer could die before it started, with "Out-of-order frame: expected 0, got 1".** Intermittently, and most often from the command line. Every control message — the file's description, the replies to it, and the note each side sends about the network route — carries a number that becomes part of its encryption, and the receiving side refuses one that arrives out of turn. Each was numbered, then encrypted, then sent, so two sent at the same moment could reach the other device in whichever order they finished encrypting — and the command line sends its route note and the file description back to back. Numbering, encrypting and sending are now one step, taken in order for each direction. Nothing changes on the wire, so an older version on the other end is unaffected. The same message had a second cause, found by running two copies of the command line against each other. A device threw away anything the other sent before it was ready to listen, and the command line is not ready until its person has confirmed the four symbols — while the other device may have been confirmed first and already sent its route note. That lost note made the next message look out of turn. Messages from the paired device that arrive early are now kept, a bounded few of them, until this side is ready. This affected the command line receiving from another command line or from the website, and the command line sending to another command line. For code using the package directly: `sendControl` is now exported, and the ordering asks one thing of callers — give `createReceiver`, `sendFile` and `sendPathVerdict` on one side the same `nextControlIndex` function, not separate closures over one counter. **A receive that fails says why.** Any failure to open the place a file was being saved used to be reported as the save dialog having been closed, and the sender was told the file had been declined. The real error now reaches both screens. And when a receive failed partway, every piece of the file still in flight failed again and replaced the message, so the one on screen was almost never the cause; the first failure now ends the receive and is the one shown. **A cancelled or interrupted receive does not leave a partial file.** Cancel in the browser now abandons what was written, leaving the empty file the save dialog created, as a Beam receive already did. The command line deletes the partial file when the sender disconnects mid-transfer or when you press Ctrl-C; its Ctrl-C handler had long said it did this, and did not. **Ctrl-C on the command line tells the other device.** It used to exit without leaving the connection, so the other side carried on until the connection timed out: a sender went on showing "Sent 340 MB" for 11 seconds after its receiver had been interrupted. Measured again with the fix, the sender reported the disconnect within a tenth of a second. For code using the package directly: a paired room's `close()` now returns a promise that settles once the peer has been told, and never rejects. A process about to exit should await it. **A send whose receiver leaves partway through says so, and stops.** It used to keep sending the rest of the file to nobody and then wait forever for a reply that was never coming — the command line printed "Sent 64 MB of 64 MB" and was still running two minutes later, and the browser showed the disconnect while the send carried on underneath it. The send now ends at the next piece of the file with "The other device disconnected". For code using the package directly: a control stream's `fail()` now lasts. Every later `next()` and the flow gate reject with it, once no message that had already arrived answers the wait, and a new `throwIfFailed()` is what `sendFile` checks between chunks. **A receive in the browser whose sender leaves says so, and stops.** The receiving side had the same gap as the send above, most visibly when the sender left while the save dialog was open: choosing a location then drew "Receiving, 0%" over the error, and it stayed there. The receive now ends on "Transfer failed" with "The other device disconnected" and gives up the file it had opened. The command line was not affected. **A finished send is no longer reported as "The other device disconnected".** The file arrived whole and both ends computed its digest, and then the sender said the other device had gone and the command line exited 1. The receiving side sends "done" and closes, and both reach the sender in that order — but "done" still has to be decrypted when the news of the close is handled, so the close won. The sender now finishes reading what had already arrived before it treats the other side as gone. A decline, or an error the receiver reported just before closing, had the same problem and now reaches the sender as what it was. This is the intermittent failure of the command-line interop test, which had been blamed on the receiver closing before its reply was sent. For code using the package directly: `createReceiver` returns a `settled()` that resolves once every frame already handed to it has been processed. Wait on it before failing a control stream because the peer left. For code using the package directly: a `createSink` may now resolve `null` to mean the person chose not to save, which declines, while a rejection is a failure that `accept()` passes back to its caller rather than turning into a decline. The web `createSink` resolves `null` on a dismissed picker instead of rejecting with `AbortError`. `createReceiver` returns a `cancel()` that aborts the open sink. ## 0.5.0 — 2026-09-11 **A mismatch on the verify screen now has a button, and it ends the session.** The screen asks whether both devices show the same four symbols, and it used to offer only "They match" and a neutral Cancel — so someone looking at symbols that differed had nothing to press that meant what they were seeing, and Cancel dropped them back on the file picker without a word. "They don't match" now ends the pairing and says what that means: something is relaying between the two devices, nothing about the file left this one, not even its name, and starting over means a fresh code. That last part is the point. Four symbols out of 64 is 24 bits, which is strong only if an attacker gets a single attempt; a retry that showed the same code again would hand them another. So there is deliberately no retry, and a test now fails if one is ever added. This is on the sender's side for now. **The threat model says how long a code stays live.** Pairing latches onto the first device to arrive, and a code has no expiry, so a QR can be used for exactly as long as the sender sits on the QR screen waiting — which is also when a screen share or a recording is most likely to be running. The README now says that plainly instead of stopping at "show it to a person, not a room". A new test also checks that the code itself never reaches a relay in any encoding, which was always true and is now enforced. **A phone held sideways is now its own layout, not a squeezed portrait one.** Rotating the phone used to put the scanner panel and the camera frame on top of the buttons underneath them, and cost the beam screen its heading and its encryption warning. Two faults met there: a landscape phone is short without being wide, which was the one shape none of the layout's branches were written for, so the card laid out in a single column and the media block collapsed to nothing under boxes that refused to shrink; and the copy column was centred, which pushes anything that does not fit out of *both* ends — and the top of a scrolling column cannot be scrolled back to. Fixing that made it legal. What it did not do is make it good: 39% of a 360px-tall screen was going on a page header, a footer and a full-width row of buttons, all stacked on the one axis there was no room on, while 800 horizontal pixels went spare. The content was laying out inside 111 pixels. So in landscape the page title, "How it works" and the security disclosure now sit on one line instead of three; the card puts the camera or the code down the left at full height, with the words and the buttons beside it; and beam's speed control moves down next to Cancel where the room is. Nothing is hidden and nothing moved behind a tap. The viewfinder went from 128 pixels to 258, and the room for words from 111 to 194 — every screen but one now fits with nothing to scroll at all, and that one shows its warning and its instruction before anything scrolls. The layout check gained both landscape sizes, an assertion for content pushed above its own scroll origin — the fault that had been invisible to it — and a per-viewport budget for the one screen that cannot fit, so the rest cannot quietly start overflowing behind it. **On a phone, qrdrop opens as a scanner.** The choose screen's biggest element used to be a second "Send a file" button — it took every spare pixel of the card to offer what the button below it already offered, and in landscape it spent the whole left half of the screen doing it. On a touch device with a camera it is now the way into the scanner, and the action bar keeps sending. Two things the app does, two regions, neither said twice. On a mouse nothing changed: it is still the drop target, because dragging is still the fastest way to send and a thumb still cannot do it. **The screen no longer goes to sleep mid-transfer.** A beam runs for minutes with the phone held up to another device's camera and nothing touching the screen, which is exactly what a display timeout counts — so the code being read would go dark part-way through. A large file over a slow link had the same problem. The display is now held awake from the moment a transfer starts until it ends, and re-held after you glance at a notification, which the platform would otherwise not give back. **The back gesture asks before throwing a transfer away.** It used to close the app, mid-transfer, without a word — in the packaged app, in an installed PWA, and in a browser tab. Now the first press says so and the second cancels. It never moves between screens and never dismisses the SAS confirmation or beam's Accept: those are the two gestures nothing is allowed to advance past, and a gesture that could dismiss them would be the same bug from the other side. **The UI works on a touch screen, which it did not.** The choose screen's biggest, most eye-catching element was a dashed box reading "Drop a file here" — an instruction a phone cannot follow, sitting above the two buttons that could. It is a button now, at every pointer, so tapping it picks a file; on a mouse the same box became clickable, which it had never been. Buttons meet a 48px floor on a touch pointer, sheets come up from the bottom of the screen instead of the middle, and a phone gets the page chrome's pixels back for the card. **The safe-area padding was doing nothing.** `env(safe-area-inset-*)` resolves to zero unless the viewport meta opts in with `viewport-fit=cover`, and it did not — so the notch and home-indicator handling written into the stylesheet had never once applied. Opting in also exposed a narrow-screen rule that dropped all four insets through a `padding` shorthand. **qrdrop installs to a home screen, and appears in the share sheet.** A generated web manifest makes the site installable, and a share target lets someone send a photo from Photos or a document from Files straight into it instead of opening the app and navigating back to the file they were already looking at. The service worker that makes the share POST answerable caches nothing at all, deliberately. **A camera button, on touch pointers.** The thing most often worth sending from a phone is the photo in front of you, and reaching it through a file browser is two dialogs and a folder tree. **The CLI installs from Scoop on Windows.** `scoop bucket add stan-ely https://github.com/stan-ely/scoop-bucket`, then `scoop install stan-ely/qrdrop`. The manifest points at the npm registry tarball and depends on `nodejs` from the main bucket, so it is the Homebrew formula's shape rather than a vendored runtime — which is what the README's "no winget" reasoning had ruled out, and Scoop is not that case: it has both the dependency and the install-hook that winget lacks. ## 0.4.0 — 2026-09-06 **Privacy: the camera could stay on after a scan was cancelled.** `scanQRStream` registered its abort listener three awaits after opening the camera, so an abort arriving during `getUserMedia`, `video.play()` or `createDetector()` found no listener — and a signal that has already fired never fires again, so the scan promise never settled and the `finally` that stops the track never ran. The webcam then stayed on for the life of the page. The way in is ordinary rather than exotic: the hand-entered code field lives on the scanner screen, so **pairing by pasted code aborted mid-open every time**. It was found by a light on a laptop while the whole suite passed, because nothing headless can observe a camera that was never released. If you embed ``, this is the reason to update. **Sending a file from a phone was between 12 and 18 times slower than it needed to be.** `fromFile` read once per 16 KiB frame, and on Android a `File` picked through the Storage Access Framework is backed by a `content://` provider — so every read was a Binder round-trip to another process, ~79 ms fixed plus ~3.7 ms per MiB. A 3 MB send spent 29.7 s of a 30.3 s transfer inside those reads: 98% of it, against 21 ms of AEAD and 9 ms of data channel. It now reads in 2 MiB blocks and issues the *next* block's read as soon as the current one lands, so the round-trip overlaps the ~128 frames still going out. Measured on a real device: 3 MB went 30.2 s → 3.34 s, and 64 MiB went to 16.8 s at 4.03 MB/s, where the per-frame path would have taken about eleven minutes. Across 31 block boundaries the read-ahead was late zero times, so reads now cost the transfer nothing. This was never a Tauri problem, which is why the fix is in `src/web/source.js` and reaches everyone: Chrome on the same phone passes the `content://` through the same way, so the deployed site had it too, at 0.22 MB/s for **every Android sender**. Desktop browsers were unaffected — a `File` there is backed by a real filesystem — and the CLI uses a different adapter entirely, which is exactly why it survived until someone pointed a phone at it. **A slow sink no longer buffers the whole file in memory.** `RTCDataChannel` delivers messages as fast as they arrive and the receiver serialised them into a promise chain, so SCTP flow control never engaged — the receiver never stopped reading. A sink that drained slower than the channel filled therefore grew the heap by the size of the transfer; measured at its worst, 1 GiB of heap over a 1 GiB file. The receiver now tracks bytes accepted but not yet written and sends an authenticated `pause` control message above a threshold, `resume` below one, with a timeout so an indefinitely-paused transfer fails with a diagnostic rather than hanging. Both directions degrade cleanly: a sender too old to act on `pause` drops it as an unrecognised control type, and a receiver too old to send one simply never does. **Fixes.** A sealed chunk now fits in one transport message. `CHUNK_SIZE` was a flat 16 KiB, which overshot the action wire's per-message budget by 66 bytes — and the transport does not reject an overshoot, it splits it, so every frame travelled as a full message plus a 102-byte runt (measured: 393 sends for 192 frames). Inbound tolerance is deliberately unchanged, since a peer on the old constant still sends 16414-byte frames. **New.** A platform seam (`qrdrop/web`'s `registerPlatform`) lets a host application supply its own file sink without the deployed site being able to regress from it. `` now consumes a code set on `location.hash` after load, guarded to the choose screen so it cannot interrupt a live transfer — the mechanism behind opening a scanned link in an installed app. Both still take the code only from the fragment, never the query string. **A desktop and mobile app now exists**, built from this same code and released on its own `app-v*` tags with its own changelog. See [docs/app.md](docs/app.md) for what each platform can and cannot do. **If you self-host the browser bundle, redeploy.** The camera fix and the mobile send performance both live in it. ## 0.3.1 — 2026-08-30 **Security: a stranger in the rendezvous room could end a transfer.** Frame headers were checked for type, file sequence and chunk index *before* AES-GCM ran, and the transport accepted frames from any peer in the room rather than only the paired one. Together those meant anyone who could put a single packet into the room — no code, no pairing, never holding a key — could kill a live transfer with fourteen bytes of well-formed cleartext: the receiver refused the frame, aborted its sink, and discarded the partial file. Reported from the field as `Out-of-order frame: expected 0, got 13877` on a sub-1 MB transfer, which has about 64 chunks in it. Confidentiality and integrity were never affected — the attacker could not read, forge or alter file contents, and nothing unauthenticated ever reached a sink. Availability was. Frames are now authenticated before their headers are trusted, and a frame that fails its tag is dropped and counted (`receiver.dropped`) rather than treated as a fault in the transfer; inbound frames are filtered by the paired peer, matching the targeting the send side has always done. **If you self-host the browser bundle, redeploy.** **Fixes.** A failed transfer now closes its room, so frames still in flight stop re-entering a dead state machine and firing one error control frame each. Internal failure text no longer reaches the user verbatim — the error sheet says what happened in plain words, with the raw message under a Details disclosure for a bug report. The pairing QR and the beam stage stay square when the window is short: the wide-layout branch pinned their width while the card went on squeezing their height, which paid out as a white band beside a code pinned to the corner. **Layout sweep.** `scripts/check-layout.mjs` now runs Firefox as well as Chromium, and asserts the QR and beam boxes are actually square — the check that caught the above. It needs `npx playwright install firefox` once. **Distribution.** `brew install stan-ely/tap/qrdrop` installs the CLI on macOS and Linux; the formula is rewritten from the release workflow, so it cannot drift from what was published. Tags now produce a GitHub Release as well as an npm publish, with notes taken from this file rather than generated from commit subjects. Each release carries the npm tarball, `qrdrop-site-.zip` — the built browser bundle, for self-hosting, which is what the redeploy note above was asking for and previously had nowhere to point — a `SHA256SUMS` file, and a build provenance attestation over both archives. No winget: it cannot install a Node CLI without a bundled runtime, and Windows already has `npx`, `npm i -g`, and the deployed site. ## 0.3.0 — 2026-08-30 **Web UI overhaul.** Screens now animate on entrance and as you step through the transfer. Info sheets hold long-form copy — reassurance text, warnings, and explanations — keeping them off the main layout and freeing mobile screens from vertical scroll. The UI role system collapsed to three clear treatments. A choose-screen heading and step dots guide navigation. Media lays beside text on short windows instead of stacking. **Path reporting and verdicts.** Both peers exchange what they learned about which route the connection took (LAN, IPv4, IPv6, cost) and show the same answer instead of guessing. Route selection is logged to the path debug page (`?debug=path`), showing every candidate pair and why it won. The sender warns when the path has a cost (metered carrier or VPN). **Beam demo.** The README now leads with a video of a live file transfer, filmed from both the sender and receiver at once. The loop was one real transfer, not staged. **Fixes.** Styles render without constructable stylesheets for better compatibility. Transfer screens get a media block so layout doesn't depend on content. Beam offers are answered on the sheet only, leaving a way back. Unrecognized control messages are ignored instead of crashing. LAN hops are recognized when only one peer resolves the mDNS name. IPv4-mapped IPv6 addresses are judged as IPv4. **Documentation.** The README is restructured with three rendered diagrams, screenshots of the real UI, a contents strip, and deep-dive sections folded into details. Two load-bearing warnings (the SAS that `--yes` cannot skip, and backpressure) are raised into callouts. Added `SECURITY.md`, `CONTRIBUTING.md`, and `docs/diagrams/*.mmd` sources rendered to PNG. Social preview tags moved before their explanation to stay within crawlers' byte budgets. ## 0.2.0 — 2026-08-29 The air-gap release. **Beam: file transfer with no network at all.** The sender animates QR codes on screen at about ten frames a second, the receiver points a camera at them, and nothing crosses a wire. The payload is a [fountain code](https://en.wikipedia.org/wiki/Luby_transform_code), so the receiver needs *enough* frames rather than *particular* ones — at 30% frame loss that is 2.08×N frames against the 10.7×N a numbered loop would cost. The first N frames are sent plain, so a clean capture costs exactly N. The manifest is interleaved every 20 frames, so a receiver can join mid-stream. About 6 kB/s, with a 1 MiB cap applied after gzip. Beam is **not encrypted and cannot be**: no handshake means no key agreement, no forward secrecy, and no SAS. Both beam screens say so, and the README carves it out of the threat model rather than letting the document's other claims appear to cover it. **`qrdrop web`** serves the browser UI from the installed package on `http://127.0.0.1:4173` — the way to use the browser flow while running code you can read first. Loopback only: `http://` is not a secure context, so WebCrypto and the camera would fail there. `site/dist/` now ships in the tarball, built by `prepublishOnly`, so nothing is compiled at `npx` time. **Releases are published over OIDC trusted publishing**, with provenance. There is no longer an `NPM_TOKEN` anywhere. **The site** gained a three-step orientation strip above the transfer UI, an Open Graph card (`site/og.png`, generated by `scripts/make-og.mjs`), links out to source, issues and author, and a content-hashed stylesheet — GitHub Pages serves `max-age=600` and ignores `site/_headers`, so a stale stylesheet was being served against fresh markup. **Fixes.** `node-datachannel` is pinned to 0.33.0, the last version with a complete platform set. `adopt` is honoured when an element is created, not only when it is patched. The pairing screen says each thing once, and buttons admit they were clicked. **Credit.** [qrbeam](https://www.npmjs.com/package/qrbeam) is where the idea of beaming a file over animated QR came from, and [txqr](https://github.com/divan/txqr) got to the fountain code first. Neither is a strawman and no code was taken from either. ## 0.1.0 The first published version: the WebRTC path, the three entry points, and the CLI. It was released without a git tag, so its exact boundary cannot be reconstructed from this repository — reproducing it here would be a guess presented as a record. `git log` up to `b7e661f` is the honest answer. Tagging starts at 0.2.0.