Send a file between two devices using nothing but a screen and a camera. One device shows the file as an endless stream of animated QR codes; the other points its camera at the screen and reconstructs the file. No network path between the devices, no pairing, no server — the payload travels as light.
This is a Tauri v2 native app on Windows, macOS, Linux, Android, and iOS, with two tabs:
- Send — pick any file with the native file picker; it immediately starts streaming as animated QR codes in a loop.
- Receive — start the camera, point it at the sender's screen, and the file is decoded and saved to your Downloads folder (with a Save As… fallback).
Online Send/Receive of Opfer support upto 30 fps only, use mobile version for better speed.
The transfer uses LT (Luby transform) fountain codes so the one-way optical channel needs no back-channel and tolerates dropped frames:
- The sender never transmits the file's blocks directly. Each QR frame is the XOR of a pseudorandom subset of blocks, chosen deterministically from the frame's sequence number (robust-soliton degree distribution).
- The receiver collects any ~
K × 1.15distinct frames, in any order, and peels the file out of them. A missed frame (blur, refresh straddling, autofocus hunt) costs a little time, never correctness. Sender and receiver frame rates don't need to match. - Every 20-byte frame header is self-describing (session id, seq, block count, block size, total length, FNV-1a hash). There is no handshake: the receiver locks onto a stream mid-flight, and restarting the sender (new session id) automatically resets the receiver.
Decoding uses zxing-cpp compiled to
WASM, run in Web Workers fed by requestVideoFrameCallback. Busy workers drop
frames — the fountain absorbs them.
- Any file, not a bundled image. The file name travels inside the payload
(see
src/container.ts:u16 nameLen | name | bytes), so the receiver saves it under its real name and extension. The wire protocol and LT codec (src/shared/) are unchanged. - Native file pick + native save via
@tauri-apps/plugin-dialogand@tauri-apps/plugin-fs, so it works the same on desktop and mobile — no browser download plumbing, no self-signed dev cert. - Single app, two tabs instead of two web pages.
index.html two-tab shell (Send / Receive)
src/main.ts tab switching + lifecycle (stops camera/QR loop on tab change)
src/send.ts file pick → LT encode → animated QR canvas loop
src/receive.ts camera → workers → LT decode → save to Downloads
src/worker.ts zxing-wasm QR decode worker
src/container.ts filename wrapper (wrap/unwrap)
src/shared/protocol.ts frame header + FNV-1a + splitmix32 (unchanged from PoC)
src/shared/fountain.ts LT encoder/decoder + robust-soliton (unchanged from PoC)
src-tauri/ Rust host: dialog + fs + opener plugins
- Node.js 18+ and npm
- Rust (stable) + the
Tauri system dependencies for your OS
(WebView2 on Windows — preinstalled on Win 11;
webkit2gtkon Linux; Xcode on macOS/iOS; Android Studio + NDK for Android).
npm install
npm run tauri dev # dev build with hot reload
npm run tauri build # production bundle / installerTwo devices are needed to actually transfer: open the app on both, pick Send on one (choose a file, crank screen brightness), and Receive on the other, then point its camera at the sender's screen.
You can also run both tabs on one machine to smoke-test the UI — Send in one window, Receive in another, camera pointed at the first screen.
Tauri builds the same codebase to native mobile targets:
# Android (needs Android Studio, SDK, NDK, and JAVA_HOME set)
npm run tauri android init
npm run tauri android dev # on a connected device / emulator
# iOS (macOS + Xcode only)
npm run tauri ios init
npm run tauri ios devThe receiver uses the webview's getUserMedia. Each platform gates the camera
differently:
| Platform | What's needed | Status in this repo |
|---|---|---|
| Windows (WebView2) | Host must grant the WebView2 permission | Handled in src-tauri/src/lib.rs: the app auto-allows camera/mic permission requests, so a previously-remembered Block can't leave the camera stuck — pressing Start camera always works. |
| macOS (WKWebView) | NSCameraUsageDescription + camera entitlement |
Provided in src-tauri/Info.plist and src-tauri/Entitlements.plist. |
| iOS | NSCameraUsageDescription |
Add the key to src-tauri/gen/apple/*/Info.plist after ios init (copy from src-tauri/Info.plist). |
| Android | Manifest CAMERA permission + feature |
After android init, add to src-tauri/gen/android/app/src/main/AndroidManifest.xml: <uses-permission android:name="android.permission.CAMERA"/> and <uses-feature android:name="android.hardware.camera" android:required="false"/>. Android also gates the webview separately, so the in-app request triggers the OS prompt. |
If the camera can't start, the Receive tab shows an explanatory message instead of failing silently.
Writes go through a native Rust command, write_file_raw,
that receives the bytes as the raw IPC request body (tauri::ipc::Request)
and writes them with std::fs — avoiding the JSON-array marshaling that makes
the JS↔native bridge crawl on large files. Body layout is [u32 pathLen][path] [data]; Rust creates parent dirs and de-dups the name ((1), (2)…). The UI
shows an immediate Saving … status and never drops the file — every path
has a fallback.
- Windows / macOS / Linux — written straight into the user-profile Downloads folder (fast native write); a Show in folder button is shown.
- Android — tries a fast native write to the public Download folder
(
/storage/emulated/0/Download). That works on Android ≤10 (legacy storage); on 11+ scoped storage blocks it, so it falls back to the system Save (SAF) sheet into the public Downloads, and if even that is cancelled it keeps the file in app storage rather than losing it.
Received images are also previewed inline.
Both tabs have a collapsed Settings panel.
Send:
| setting | default | notes |
|---|---|---|
| tx fps | 24 | each frame must own ≥2 refresh cycles of the display |
| bytes / frame | 1465 (≈ QR v27) | denser is faster only if the receiver still decodes it; 2953 (v40) works phone-to-phone at close range but tanks throughput if the camera can't resolve it |
| codes (grid) | 1 | show a grid × grid tiling of QR codes at once. 2 → 4 codes/frame (~4× data), 3 → 9 codes (~9×) — if the receiver still decodes them. Each code is 1/grid the size, so needs a bigger display / closer, steadier camera |
| color layers ×3 | off | pack three codes into the R/G/B channels of each cell (~3× on top of the grid). Experimental — camera white-balance and chroma subsampling bleed channels, so it's fragile and can decode worse than mono. The receiver's "color decode" must match |
| error correction | L | the fountain layer already handles erasures; a frame decodes whole or is dropped |
| display size | 900 px | larger QR = easier decode, more screen used |
Receive: capture width (1280 is the widest mode iOS runs at true 60 fps),
capture fps (demanded with exact first — ideal: 60 silently delivers 30),
decode workers (auto-set to the device's core count — QR decoding is CPU-bound,
so parallelism up to the cores is the lever; past that it stops helping),
color decode (split each frame into R/G/B planes — match the sender's color
layers), and fast decode (auto/off/on). Fast decode turns zxing's
tryHarder off — ≈2× faster per decode at the cost of some marginal codes. In
auto (default) the app watches the frame-drop metric and only latches it on
if decode falls behind capture, so it self-tunes toward keeping up without giving
up thoroughness when it isn't needed.
The decoder always skips zxing's rotation and inversion passes (the codes are
always upright and black-on-white) — a safe decoded-fps win. The heavier
tryHarder/tryDownscale passes stay on unless you enable fast decode.
Hold the receiving device steady or prop it — autofocus hunting from hand tremor is the #1 throughput killer.
The parent experiment reached ~128 KB/s with stacked multi-code grids and an error-corrected color channel on top of the base trick. Both are now built in as opt-in settings (defaults keep the reliable single mono QR):
- Grid (multi-code). The sender tiles
grid²independent fountain frames per image; the receiver asks zxing for up to 9 codes per frame and feeds each to the decoder. Throughput scales ~grid²as long as every code still decodes — pushgridup while watching the receiver's decode fps, and back off when it drops. Bigger/brighter sender display and a closer, propped camera buy you more grid. - Color (RGB ×3). Each cell carries three codes, one per channel. All codes of a fixed QR version share identical finder patterns (black in every channel), so each layer stays independently decodable in theory. In practice it's fragile — treat it as experimental and verify decode fps holds before trusting it.
Realistic guidance: grid 2–3 is the dependable lever; color is a gamble that depends heavily on the specific camera. Tune both against the live decode-fps metric, not the paper multiplier.
The Android WebView caps getUserMedia at 30 fps on many devices, which
limits how many frames the receiver can decode. The Receive tab has a native
camera (Android) toggle that routes around it: a Rust module
(src-tauri/src/camera_android.rs) opens the
camera directly through the NDK Camera2 API, requests the target fps, decodes
every frame's Y (luma) plane natively with rxing (binary-safe, multi-code),
and streams only the decoded frame bytes to the frontend over a Tauri Channel.
Raw frames never cross the IPC bridge (60 fps of YUV would be ~110 MB/s), so the
bridge stays trivial and the WASM/OffscreenCanvas decode path is bypassed.
- Enable it in Receive settings → native camera → on. There's no live preview in this mode (frames don't enter the webview).
- Cross-compiles for all four Android ABIs; build with
build_android_all.bat(set your SDK/NDK paths) ornpm run tauri android build. - Uses the
CAMERApermission (already in the manifest) and picks the first camera id (rear on virtually all devices). If a device only reaches 60 fps via a constrained high-speed session, see the note incamera_android.rs.
Status: the native module cross-compiles cleanly (verified against NDK r27d,
aarch64-linux-android), but the camera2 runtime path can only be validated on a physical device — treat it as ready-to-test, not battle-tested.
The app icon is a QR matrix (the app's own motif) in the brand amber on a dark rounded card. It's generated from a single source and fanned out to every platform's sizes:
node scripts/generate-icon.mjs # writes app-icon.svg (edit the script to restyle)
npx tauri icon app-icon.svg # regenerates all platform iconstauri icon populates:
- Windows —
src-tauri/icons/icon.ico(multi-size ICO, referenced bybundle.iconintauri.conf.json). - Android — every mipmap density (
mdpi→xxxhdpi) undersrc-tauri/gen/android/app/src/main/res/mipmap-*/(ic_launcher,ic_launcher_round, and the adaptive-iconic_launcher_foreground). The adaptive background color is set to the brand dark inres/values/ic_launcher_background.xml. - macOS / iOS / Linux —
.icns, the iOSAppIcon-*set, and desktop PNGs.
- Concept, protocol, and fountain implementation: decimen-optical-transfer by bashalarmistalt (MIT).
- node-qrcode (generation) and zxing-wasm (decoding).
MIT