# App capabilities and measurements The app's lab notebook: what each platform's webview could actually do, and every measurement behind the choices in the shell, recorded phase by phase as the app was onboarded. It is dated rather than current -- a later phase supersedes an earlier one where they disagree (Phase 0's "not run" cells for Android were answered in Phase 4). For what the app does today, read [docs/app.md](../docs/app.md). ## Phase 0: capability spike What `app/spike/index.html` found when loaded in a real Tauri window on each target. This is the gate for Phase 1: it exists to answer "does the webview on this platform actually have what `src/web/` needs" before any of the real frontend is wired in, not to be a permanent report -- once Phase 1 replaces `app/spike/` with the built site, this file stops being generated and stands as a record of what was true when the app was onboarded. Every check and its rationale is commented in `app/spike/index.html`; this file only records outcomes. ## Summary | | Windows (WebView2) | Linux (WebKitGTK) | macOS (WKWebView) | Android | iOS | | --- | --- | --- | --- | --- | --- | | secure context | pass | pass | pass | not run | not run | | WebCrypto | pass | pass | pass | not run | not run | | `showSaveFilePicker` | pass | fail (expected) | fail (expected) | not run | not run | | `BarcodeDetector` | fail | fail | fail | not run | not run | | `getUserMedia` (API present) | pass | pass | pass | not run | not run | | `RTCPeerConnection` + ICE | pass | **FAIL -- absent** | pass | not run | not run | | `wss://` connect | pass | pass | pass | not run | not run | The one result that changes the shape of the project: **Linux/WebKitGTK has no WebRTC at all**, confirming the risk the onboarding brief called out by name. See the Linux section below for what that does and does not rule out. Android and iOS are not run yet -- see their sections for why. ## Windows (WebView2 / Chromium, run locally) Built with `npx tauri build --debug --no-bundle` from `app/`, run directly as `qrdrop.exe`. WebView2's UA reports Chrome/152 (Edg/152) -- current Chromium, as expected for WebView2's evergreen runtime. | Check | Result | | --- | --- | | `isSecureContext` | **pass** -- `true` | | `crypto.subtle` | **pass** -- present | | `showSaveFilePicker` | **pass** -- present (Chromium) | | `BarcodeDetector` | **fail** -- not present. Chromium ships the Shape Detection API behind a component that WebView2 does not carry; the jsQR fallback (`src/web/qr.js`) is load-bearing on Windows, not a Firefox-only path. | | `getUserMedia` | **pass** (API present) -- but the call itself rejects `NotAllowedError: Permission denied`, immediately, with no OS permission prompt shown. WebView2 does not surface a camera-permission dialog on its own; Phase 3 needs an explicit permission handler (`WebView2`'s `PermissionRequested` event, exposed through `tauri-plugin-*` or a custom listener) or every camera use on Windows silently fails closed. | | `RTCPeerConnection` + ICE gather | **pass** -- a host candidate arrived immediately. Note: `iceGatheringState` never reached `complete` within 15s even after STUN produced three `srflx` candidates in one run -- see below. | | `wss://` connect | **pass** -- connected to `wss://nos.lol` | **Finding, not a failure:** the first version of this probe waited for `iceGatheringState === 'complete'` and reported ICE as failing after 15s, even though host and three `srflx` candidates had already arrived -- a fully usable ICE setup. `complete` appears to hang indefinitely on this machine, most likely on the two `openrelay.metered.ca` TURN entries in `ICE_SERVERS`. The probe was rewritten to resolve on the *first usable candidate*, which is what real signalling actually waits for (Trystero negotiates over trickle ICE, not after `complete`) -- see the comment in `app/spike/index.html`. Left as recorded here because the same trap is easy to reintroduce in Phase 1/2 code that assumes `complete` fires: it may not, on this platform, in this environment. ## Linux (WebKitGTK, CI: ubuntu-latest, run under Xvfb) UA reports WebKitGTK's Safari/605.1.15 (`Version/60.5`). | Check | Result | | --- | --- | | `isSecureContext` | **pass** -- `true` | | `crypto.subtle` | **pass** -- present | | `showSaveFilePicker` | **fail** -- absent, as expected: this is a Chromium-only API and Phase 2's platform seam exists precisely to route around it here. | | `BarcodeDetector` | **fail** -- absent. jsQR (`src/web/qr.js`) is the only working path on this platform. | | `getUserMedia` | **pass** (API present), call rejects `OverconstrainedError: Invalid constraint` for `{ video: true }` with no camera attached. Distinct from Windows' immediate `NotAllowedError` -- worth re-testing against a real device before drawing a permission-flow conclusion from a headless runner with no camera at all. **Also:** the very first run of this job used a 30s process budget and never got a result at all, because WebKitGTK's native media-capture negotiation ("Video capture was requested but no device was found amongst 0 devices") blocks past this page's 5s JS-level `getUserMedia` timeout before the promise rejects -- the timeout does not preempt it. The job now waits 75s; Phase 3's real camera-permission flow needs to budget for the same thing rather than assuming a 5s JS timeout actually bounds the call. | | `RTCPeerConnection` + ICE gather | **FAIL -- `RTCPeerConnection is not defined`.** This is the named risk in the onboarding brief, confirmed: the WebKitGTK build on ubuntu-latest ships with no WebRTC support at all. Not "gathers no candidates" -- the constructor itself does not exist. | | `wss://` connect | **pass** -- connected to `wss://nos.lol` | **This is the Phase 0 result that gates everything downstream of it.** qrdrop's entire WebRTC transfer path -- the thing the app exists to do -- cannot run in this webview as built. Two honest paths forward, neither attempted here because the decision belongs to whoever reviews this phase, not to the spike: 1. Ship Linux WITHOUT the WebRTC transfer mode. Beam (camera + animated QR, no networking) does not depend on `RTCPeerConnection` and could still work -- pending its own camera-permission verification. CLI-over-stdin already works regardless of the webview. A Linux desktop build would then be a materially smaller feature set than Windows/macOS, and the UI would need to say so rather than offer a WebRTC send/receive screen that silently cannot connect. 2. Investigate whether WebRTC can be enabled on this WebKitGTK build -- distribution packaging, a `WEBKIT_DISABLE_COMPOSITING_MODE` or feature-flag equivalent, or a newer WebKitGTK version than what ubuntu-latest carries by default. Not explored here; it is exactly the kind of "work around it silently" the onboarding brief said not to do without saying so. **Decision: option 1.** Cross-checked outside the CI runner before deciding, since ubuntu-latest's image is one specific build a real user is not guaranteed to have. The real, built Phase 1 app (`app/dist`, not the Phase 0 spike page) was compiled and run under WSL2 (Ubuntu 24.04.2, `libwebkit2gtk-4.1` 2.52.3 -- newer than ubuntu-latest's CI image) against a real WSLg display, both directly and via `npx tauri dev`. `RTCPeerConnection` is `undefined` there too, confirming the CI result was not an artifact of that one runner image. (One dead end on the way there, noted so it is not repeated: forcing `GDK_BACKEND=x11` and relaunching the raw binary repeatedly, rather than through `tauri dev`, put the webview into a state where every navigation failed with WebKit's own "Operation was cancelled" error page before any content loaded -- unrelated to WebRTC, and it went away on a clean relaunch through `tauri dev` with no backend override.) Option 2 was also checked against public sources rather than left as a guess: [tauri-apps/tauri discussion #8426](https://github.com/tauri-apps/tauri/discussions/8426) gets WebRTC working in WebKitGTK on Linux, but only by compiling WebKit itself from source with `-DENABLE_MEDIA_STREAM=ON -DENABLE_WEB_RTC=ON`, patching Wry to enable the corresponding WebKitSettings and handle permission requests, and running it on NixOS with a custom flake. That is not a packaging gap Ubuntu or Debian will close upstream on their own schedule -- it is a from-source WebKit build this project would have to compile and maintain itself, on every target distro, indefinitely. Weighed against that cost, shipping Linux without WebRTC transfer (Beam and the CLI still work) is the honest option, not the lazy one. ## macOS (WKWebView, CI: macos-latest) UA reports WebKit's Safari/605.1.15 (WKWebView, no numbered Safari version -- normal for an embedded webview). | Check | Result | | --- | --- | | `isSecureContext` | **pass** -- `true` | | `crypto.subtle` | **pass** -- present | | `showSaveFilePicker` | **fail** -- absent, same reasoning as Linux. | | `BarcodeDetector` | **fail** -- absent. | | `getUserMedia` | **pass** (API present), same `OverconstrainedError: Invalid constraint` as Linux against a runner with no camera. | | `RTCPeerConnection` + ICE gather | **pass** -- a host candidate arrived immediately, same as Windows and the same `iceGatheringState` never reaching `complete` within this run's window. WebRTC is present and at least partially functional on WKWebView, unlike WebKitGTK -- the two WebKit-family webviews are NOT equivalent here. | | `wss://` connect | **pass** -- connected to `wss://nos.lol` | Encouraging relative to Linux: the transfer path has a real chance of working on macOS as-is. Still needs a from-a-real-device check of `getUserMedia` against an actual camera and its OS permission prompt (Beam depends on this), and of a full peer-to-peer connection rather than just "a host candidate exists" -- this spike never completes an actual handshake, by design (there is no peer in a CI job). ## Android **Not run in Phase 0.** No Android SDK/NDK was installed on this machine, and acquiring one is a multi-gigabyte download plus emulator or device setup -- deliberately not done without confirming first. Phase 4 builds the toolchain config and leaves the on-device run to whoever has the phone; the checklist to fill in is in "Phase 4" below. ## iOS **Not run in Phase 0.** Per the onboarding brief this is a CI-only, build-only target from Phase 4 onward (`macos-latest`, unsigned, ships nothing) -- there is no local iOS capability check to run from a Windows machine at any phase. Phase 4 adds that CI job; see below. ## Phase 2: platform seam and native sink Built on the findings above rather than repeating them. `src/web/platform.js` is a new, dependency-free registry -- `element.js` now asks it for `createSink`/`canStreamToDisk` instead of importing `web/sink.js` directly, and it defaults to exactly that import until something calls `registerPlatform()`. Nothing about the deployed website changes: site/main.js never calls it. `app/src/main.js` does, and it is a new, second esbuild entry point (`scripts/build-site.mjs`'s `channel === 'app'` branch points there instead of at `site/main.js`) rather than a runtime `if (isTauri)` branch inside `src/web/`, so the website's bundle carries no Tauri-only code at all. It registers `app/src/tauri-sink.js`: `@tauri-apps/plugin-dialog`'s `save()` picks the destination, and this crate's own `sink_open` / `sink_write` / `sink_close` / `sink_abort` commands (`app/src-tauri/src/sink.rs`) stream the bytes to it. ### The sink does not use plugin-fs, and the throughput number is why `tauri-sink.js` shipped first on `@tauri-apps/plugin-fs`'s `create()` / `write()` / `close()`, on the stated belief that `write()` "crosses the JS<->Rust boundary as raw bytes, not a JSON-stringified array of numbers". **That is true on paper and false on WebView2.** Measured with a throwaway two-peer harness (a Node guest sending through the real `sendFile`, the app as host + receiver, the sink writing a fixed path): | write path | block size | throughput | | --- | --- | --- | | plugin-fs `write()` | 16 KiB … 4 MiB | **~2 MB/s, flat** | | raw-body `invoke` | 16 KiB | 5.4 MB/s | | raw-body `invoke` | 256 KiB | 33.8 MB/s | | raw-body `invoke` | 1 MiB | 41.5 MB/s | plugin-fs is bandwidth-bound at ~2 MB/s no matter how the bytes are chunked -- its argument is not travelling the raw IPC path on this webview. A command that takes the bytes in the invoke request's **raw body** (`tauri::ipc::InvokeBody::Raw`) does, and reaches ~40 MB/s once the caller coalesces the 16 KiB transfer frames into >=256 KiB blocks (below that, ~3 ms of per-invoke cost dominates). So `sink.rs` is a dumb open/append/close over the raw body, and `tauri-sink.js` buffers to 1 MiB before each `invoke`. ### End-to-end, 1 GiB, real two-peer transfer | | plugin-fs sink | raw-invoke sink + 1 MiB coalescing | | --- | --- | --- | | wall time | 716 s | **113 s** | | throughput | 1.5 MB/s | **9.5 MB/s (76 Mbps)** | | peak JS-heap growth | **~1.01 GiB** (the whole file) | **72 MB** | | digest match | yes | yes | Two things came out of this: 1. **The old sink defeated its own purpose.** It streamed to disk, but it drained so much slower than frames arrived that `src/core/receiver.js`'s serialised `handleFrame` queue accumulated the entire 1 GiB in RAM -- the exact whole-file-in-memory behaviour the native sink exists to avoid. The faster sink keeps the queue bounded (~72 MB) on its own. 2. **The 9.5 MB/s ceiling is now the transport, not the sink** (which does ~40 MB/s in isolation): `node-datachannel` <-> WebView2 WebRTC plus per-16 KiB AEAD on both ends, over loopback. A real network is slower still, so on a real transfer the sink is never the bottleneck and memory stays flat by construction. There is still no explicit backpressure from a slow sink to the transport -- see the CLAUDE.md invariant added for this phase. The harness itself is gone, like `app/spike/` before it; this table is the record. `cargo build` is clean, `mise run typecheck` and `mise run test` pass, and the built window renders today's UI unchanged (Windows still shows Send/Receive, matching Phase 0's `RTCPeerConnection` pass on WebView2). ## Phase 3: camera permission and deep links ### The pairing QR is now a universal / app link `app/src/main.js` sets `base-url` to `https://share.stan-ely.com/`, so the app's QR encodes `https://share.stan-ely.com/#qrdrop:` instead of the bare `qrdrop:` form. One string, two audiences: the OS opens the installed app (it matches the registered app-link domain), and a device without the app loads share.stan-ely.com, where `site/main.js`'s `location.hash` path reads the same code. The manual-entry field still shows the bare form for a human to read aloud -- `element.js` keeps the two independent. `qrdrop:` stays registered as a custom URI scheme (`tauri.conf.json`'s `plugins.deep-link.desktop.schemes`), because that is what the CLI emits and what a person types by hand; a `qrdrop:` link anywhere on the device now opens the app too. ### The fragment-only rule survives the deep-link path `decodeSecret` accepts the code only from a URL fragment -- the one part of a URL a browser never sends to a server, which is the whole reason a link may carry a key. A deep link is an attacker-influenced string arriving from outside the app, so `app/src/deep-link.js` (`secretFromDeepLink`) funnels every shape through `decodeSecret` and re-encodes to the bare form before the caller ever writes it anywhere -- and the only place it is written is `location.hash`. A `?code=` app link therefore throws exactly as a `?code=` paste does. `test/deeplink.test.mjs` pins that across the universal-link form, the bare scheme form, a scheme handed back with an `//` authority, and the query / path rejections. `element.js` grew a `hashchange` listener (`_consumeHashCode`) so the shell can hand a scanned link to an already-running window by setting `location.hash` -- there is no fresh page load for the old `connectedCallback` read to catch. It is guarded to the choose screen, so it cannot interrupt a live transfer, and removed in `disconnectedCallback` like the paste listener. On the deployed site this also means a link pasted into the address bar mid-use is now honoured. ### Camera permission, per platform, from Phase 0's findings | Platform | What Phase 0 found | What Phase 3 does | | --- | --- | --- | | Windows / WebView2 | `getUserMedia` rejected `NotAllowedError` immediately, no OS prompt | `src/lib.rs`'s `windows_camera::allow` **grants camera + microphone up front** via `ICoreWebView2Profile4::SetPermissionState` for the `http://tauri.localhost` origin, and leaves the OS privacy toggle as the real gate. A `PermissionRequested` handler is also installed but only as a forward-compat fallback -- see below for why it is not the mechanism. `webview2-com` / `windows` are pinned to what `wry 0.55` already resolves. | | macOS / WKWebView | API present; needs a TCC usage string or the process is killed on first camera use | `app/src-tauri/Info.plist` carries `NSCameraUsageDescription`; Tauri merges it into the `.app` bundle. | | iOS / WKWebView | same as macOS (not run -- Phase 4) | `app/src-tauri/Info.ios.plist` carries the same string, committed now so it exists the first time `tauri ios init` runs. | | Linux / WebKitGTK | no `RTCPeerConnection` at all; camera path untested | no hook exposed by Tauri/wry today, and WebRTC is absent anyway -- left as a known gap, consistent with the Linux decision above. | | Android | not run -- Phase 4 | `` plus a runtime request belong in `gen/android`'s manifest, which does not exist until `tauri android init` -- deferred to Phase 4. | #### Why a `PermissionRequested` handler is not enough on Windows The first cut of `windows_camera::allow` did only the obvious thing: attach a `PermissionRequested` handler that answers `ALLOW` for camera and microphone. It compiles, the handler installs with no error -- and `getUserMedia` still throws `NotAllowedError` with no prompt, exactly as Phase 0 found with no handler at all. Traced with the WebView2 devtools protocol (`--remote-debugging-port`, `Runtime.evaluate`) plus temporary `eprintln` tracing inside the closure: the handler is reached, `add_PermissionRequested` returns `S_OK`, and **the event never fires**. `navigator.permissions.query` reports camera as `denied`, not `prompt`. WebView2's default content setting for camera on this origin is *deny*, and a denied default is resolved synchronously without ever raising `PermissionRequested` -- only the `prompt` ("ask") state routes through the event. So a reactive handler has nothing to react to; it can never move the setting off `deny`. Confirmed from the other direction: a CDP `Browser.grantPermissions(['videoCapture'])` -- which writes the Chromium content setting directly -- makes `getUserMedia` return a live `HD Camera` track on the same build, proving the OS privacy toggle and the hardware were never the blocker. `ICoreWebView2Profile4::SetPermissionState(kind, origin, ALLOW, handler)` is the write that the CDP call was doing, exposed as a real API. `allow()` now calls it for `CAMERA` and `MICROPHONE` against `http://tauri.localhost` in the `with_webview` closure, reached by `cast`-ing the `ICoreWebView2` up to `ICoreWebView2_13` for `Profile()` and the profile to `ICoreWebView2Profile4`. An older WebView2 runtime without `Profile4` (< 1.0.2210, mid-2023) falls through to the reactive handler, which is kept for that case and in case a future runtime moves the default to `ask`. The origin string is hardcoded and assumes the Windows-default `http://` custom scheme; a `tauri.conf.json` that opts into the https scheme has to change it. Verified on a real build: `permissions.query` -> `granted`, `getUserMedia` -> live `HD Camera` track, viewfinder painting in the Receive screen, and a full 3 MiB transfer from this app to a phone browser over the LAN with matching verification digests on both ends. ### Gate `mise run typecheck` and `mise run test` pass (including the new `test/deeplink.test.mjs` and the `wellKnownFiles` cases in `test/build-site.test.mjs`). `cargo check` on `app/src-tauri` is clean -- deep-link, single-instance and the Windows COM permission handler all compile. **`cargo build` / `mise run app:dev` could NOT be completed at first:** the disk filled during LLVM codegen (`rustc-LLVM ERROR: IO failure on output stream: no space on device`, ~1 GB free of 390 GB). `cargo check` -- which runs the full front end, macro expansion and borrow check over the crate and every dependency -- was clean, so this was an environment limit, not a code defect. **Resolved on a later pass.** Disk was freed, but the same cleanup had removed the MSVC C++ toolchain (only the VC++ *runtime* redists were left), so the first build then failed with `error: linker link.exe not found`. Installing **Build Tools for Visual Studio 2026** (the current line -- there is no VS newer than what the "2022" download page now serves; MSVC 14.51 + Windows SDK 10.0.26100) fixed it. `npx tauri build --debug --no-bundle`, run from a `vcvars64.bat` shell, then built clean in ~5 min cold. The running app renders today's UI, the `qrdrop:` scheme registers itself in `HKCU` and a `qrdrop:` link routes to the running instance via single-instance, the camera works (see the Windows row above), and a 3 MiB transfer to a phone browser over the LAN completed with matching digests. The only Phase 3 item still unverified is universal-link **association** -- the OS opening the installed app for an `https://share.stan-ely.com/#qrdrop:...` link -- which needs a signing identity and is deferred with the association files below. ### The association files are inert until signing exists `scripts/build-site.mjs` now writes `apple-app-site-association` (root and `.well-known/`) and `.well-known/assetlinks.json` for every channel except `app`. Both are structurally complete and deliberately dead until a signing identity is fed in: `APPLE_TEAM_ID` defaults to the placeholder `TEAMID`, and `ANDROID_CERT_FINGERPRINT` defaults to an empty fingerprint list. "Unsigned artefacts only" (onboarding brief) means neither identity exists yet; the generator takes them as environment inputs so turning association on later is a workflow secret, not a code change. `wellKnownFiles` is pure and tested. ## Phase 4: Android and iOS Everything below the toolchain notes is **expectation, not measurement**. Phase 4 lands the build configuration and the CI that compiles it; the on-device capability run is deliberately left open, and the checklists here are the form it gets filled into. Nothing in this section should be quoted as a finding until a result column says so -- the Phase 0 sections above are what measured results look like in this file. ### The toolchain: mise pins the tools, `android sdk` pulls the packages `mise.toml` pins the Android **command-line tools** (`android-sdk = "23.0"`) alongside node, rust and the JDK. An earlier pass in this phase left them out, arguing that mise auto-installs on any task so a bare `mise run test` could drag gigabytes down. That was wrong about what the plugin does: it unpacks cmdline-tools (~170 MB) and exports `ANDROID_HOME`, `ANDROID_SDK_ROOT` and the bin directories, and nothing more. The multi-gigabyte parts are pulled separately, by the tools it just installed, into the same directory. One consequence of that layout to know before running `mise uninstall android-sdk`: the NDK lives inside mise's install dir, so removing the tool takes several gigabytes of NDK with it. `JAVA_HOME` comes from mise's own `java` (Temurin 21) and deliberately wins over any system JDK. `NDK_HOME` has to be set by hand because the plugin does not provide it, and the way it is set is worth recording: the obvious `{{env.ANDROID_HOME}}` **fails**, because a tool's own exports are not in scope while `[env]` is evaluated — mise errors with "Field ANDROID_HOME is not defined". The documented `tools["android-sdk"].path` is out of scope there for the same ordering reason. `xdg_data_home` is in scope, and resolves to `AppData\Local` on Windows and `~/.local/share` elsewhere, which is where mise installs on both. One-time package install is `mise run app:android:sdk`, which expands to ``` android sdk install platform-tools ndk/29.0.14206865 \ build-tools/37.0.0 platforms/android-36 ``` from `mise.toml`'s `[vars]`. Those four versions live there once and are read by both this task and `NDK_HOME`, so a build cannot link against an NDK that was never fetched — the same reasoning that gives design tokens one home and derives the CSP from `SIGNALING_URLS`. Change the version there, re-run the task, done. Two things about that line are easy to get wrong. **`sdkmanager` is deprecated** — it prints a notice and defers to the new `android` CLI, whose subcommand is `android sdk install` and whose package separator is `/`, not the `;` every older instruction on the internet uses. And **NDK 29 is deliberate, not conservative**: every 30.x build in the repository is still a release candidate, which the package path does not reveal — only the version field's `-rc.N` suffix does, so a naive "pick the highest" lands on an RC. 29.0.14206865 is the newest stable, and NDK 28+ is also what produces the 16 KB page alignment Google Play now requires of new submissions. Version numbers rot. These were current in September 2026, taken from `android sdk list --all` rather than from memory: cmdline-tools 23.0, platform-tools 37.0.1, build-tools 37.0.0, NDK 29.0.14206865, and platforms up to android-37.2. Re-check rather than trusting this paragraph. The tasks are `app:android:init` (one-time scaffold), `app:android:dev`, `app:android:build`, and the `app:ios:*` equivalents. The `:targets` tasks add the Rust triples explicitly, before `tauri android init` would add them itself, so a missing target fails saying so instead of surfacing later as a linker error against a std that was never installed. ### `gen/` is committed source now `app/src-tauri/gen/` was ignored whole, on the argument that `tauri android init` regenerates it in one command and a committed copy would be a second source of truth. That reasoning held while `gen/` contained only generated schemas. It does not survive Android: the build needs `` in `gen/android`'s `AndroidManifest.xml`, and **`tauri.conf.json` has no field that can express it**. The delta exists only in `gen/`, so an ignored `gen/` means every re-init silently drops the camera permission and the scanner stops working with no diff to point at. A script that re-applied the delta after each init was the alternative considered, and rejected twice over: it moves the second source of truth into `scripts/` rather than removing it, and it cannot carry a Kotlin file -- which the open question below may well require. `gen/schemas/` stays ignored, because that part really is regenerated every build and never hand-edited. The manifest also gets ``. `required="false"` is deliberate: a device with no camera can still send a file and still use the network transfer, and the UI already degrades honestly (`src/web/view.js` shows `NO_CAMERA_BEAM` and hides the scan path when `cameraAvailable()` is false). Marking the camera required would make the app uninstallable there to protect a path that already declines itself. ### Open question: does the Android camera actually prompt? **This is the most likely thing to be wrong.** `getUserMedia` in the Android System WebView needs the app to hold `android.permission.CAMERA` at the OS level. The manifest entry is necessary and, on Android 6+, *not sufficient* -- `CAMERA` is a "dangerous" permission requiring a runtime request (`ActivityCompat.requestPermissions`). Whether wry/Tauri 2.11 bridges the webview's `onPermissionRequest` to that OS request automatically is version-dependent and not reliably documented; historically wry granted the webview-level resource without raising the OS dialog. Phase 4 does not assume it works. If the device run shows `getUserMedia` failing with no prompt, the two forks are a small Kotlin runtime-permission request in `gen/android`'s `MainActivity`, or a community Tauri Android-permissions plugin added to `app/src-tauri/Cargo.toml` and `app/package.json` (both app-directory files -- the "no new runtime dependencies" rule is a root-package rule). This mirrors Windows exactly: there too the reactive, obvious mechanism turned out not to fire, and the fix was to grant up front. ### Open question: the native sink on a `content://` URI `app/src-tauri/src/sink.rs`'s `sink_open` does `File::create(path)`. On Android, `@tauri-apps/plugin-dialog`'s `save()` goes through the Storage Access Framework and returns a **`content://` URI, not a filesystem path** -- which `File::create` cannot open. The Phase 2 sink may therefore fail on the first real transfer, and the throughput measured there was WebView2-specific and says nothing about Android. Two fallbacks, to be chosen on evidence rather than in advance: write into the app's own documents directory and surface the path, or route mobile writes through `plugin-fs`, which is content-URI aware. Either belongs behind the existing platform seam (`src/web/platform.js`, `app/src/tauri-sink.js`), never as an `if (isAndroid)` inside `src/web/`. ### What CI compiles `.github/workflows/app.yml` is build-only and ships nothing: a desktop `tauri build --debug --no-bundle` matrix over Linux/Windows/macOS, an unsigned Android debug APK, and an iOS simulator build. It has no tag trigger -- `v*` belongs to npm's `publish.yml` and to `pages.yml`, and app release plumbing is Phase 5. The iOS job does **not** run `tauri ios build`. That drives an archive and export, which wants a development team even for the `debugging` export method, so on a runner with no signing identity it fails for reasons that say nothing about the code. Building the simulator SDK with `CODE_SIGNING_ALLOWED=NO` compiles the Rust staticlib and links the Swift shell, which is the entire question the job exists to ask. Until this ran, nothing compiled the Rust crate on a pull request at all: `ci.yml` is Node-only and Phase 3's `cargo check` was a local habit. On a project with no Mac and no Android device in CI reach, that meant iOS and Android regressions surfaced never. ### The first Android build, and what it did and did not prove `mise run app:android:build` produces a 144 MB unsigned debug APK at `gen/android/app/build/outputs/apk/universal/debug/`. The toolchain that worked: NDK 29.0.14206865, Rust 1.98.1, Tauri CLI 2.11.4, Gradle 8.14.3, Temurin 21, `compileSdk`/`targetSdk` 36 and `minSdk` 24 (the last matching the `bundle.android.minSdkVersion` in `tauri.conf.json`, which is where it comes from). That answers the one question that could not be answered by reading: **the Rust cross-compile links against NDK 29**, so choosing the newest stable NDK over the RC-only 30.x line cost nothing. `aapt2 dump badging` on the built APK, which is the check that matters because it reads the packaged artifact rather than the source manifest: ``` uses-permission: name='android.permission.CAMERA' uses-feature-not-required: name='android.hardware.camera' ``` So the manifest edit survives into the APK and `required="false"` took effect. **A trap worth writing down, because it cost a build and the error does not name it.** This repository's prose style uses `--` as an em dash, and XML forbids that sequence *inside a comment*. A house-style comment in `AndroidManifest.xml` therefore fails the manifest merger with ``` ManifestMerger2$MergeFailureException: Error parsing .../AndroidManifest.xml ``` and no line number, no column, and no mention of hyphens. The comment in that file now says so in place. None of this is a capability result. The APK builds; whether it *runs*, and whether the camera opens, is the checklist below and needs a device. ### On-device checklist -- Android: MEASURED Run 2026-09-06 on a Realme RMX3868, **Android 16 (API 36), arm64-v8a** -- the same API level the app targets, so this is the strictest current runtime permission behaviour rather than a lenient old one. Driven over `adb` plus the WebView's own DevTools socket (`adb forward tcp:9333 localabstract:webview_devtools_remote_`), which a Tauri debug build exposes; the same CDP technique used on Windows in Phase 3. | Check | Expected | Result | | --- | --- | --- | | app installs and launches | pass | **pass** -- no crash, real UI renders | | `isSecureContext` | pass | **pass**, origin `http://tauri.localhost` as documented | | `crypto.subtle` | pass | **pass** | | `RTCPeerConnection` present | pass | **pass** -- UA is Chrome/151, evergreen | | ICE gather -> host candidate | pass | **pass** -- `192.168.1.2 ... typ host` | | `wss://` connect to a relay | pass | **pass** -- connected to `wss://nos.lol` | | choose screen shows the network buttons | pass | **pass** -- so `rtcAvailable` is true and `choose()` did not degrade | | `getUserMedia` API present | pass | **pass** | | `getUserMedia` real-camera call | open question | **pass** -- live track, `camera 0, facing back` | | OS camera prompt appears | **the decider** | **pass** -- see below | | `BarcodeDetector` | likely absent | **PRESENT** -- expectation was wrong | | `showSaveFilePicker` | absent | **PRESENT** -- expectation was wrong | | `plugin-dialog` `save()` | returns a `content://` URI | **confirmed** -- `content://com.android.providers.downloads.documents/document/1512` | | native sink accepts that path (Android as *receiver*) | suspected failure | **FAIL** -- `sink_open` -> `No such file or directory (os error 2)` | | full LAN transfer, Android as *sender* -> Windows | pass | **pass** -- 3.0 MB, digest `cf7dc838...380747`, identical to the Phase 3 value for the same file. Direct LAN path: TURN never allocated once (every request 400'd), so nothing was relayed. | | Beam send / Beam receive at ~10 Hz | pass | Beam receive **pass** (2026-09-12, 60 KB byte-identical; see "Resolved" below). Beam send **not run** | | deep link `qrdrop:` -> verify screen | record | **not run** | | `https://share.stan-ely.com/#qrdrop:` opens the app | no, needs signing | **not run** -- association still inert | #### The camera question is answered: wry raises the OS prompt This was the item flagged as most likely to be wrong, and it is not. `navigator.permissions.query({name:'camera'})` reports **`prompt`** on Android, not the `denied` that WebView2 returns, so the reactive path that failed on Windows is live here. Calling `getUserMedia` produced a real Android runtime permission dialog, and after it was granted the call returned a live rear camera track. `dumpsys package` confirms the OS-level grant: ``` android.permission.CAMERA: granted=true, flags=[ USER_SET|USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED|ONE_TIME ] ``` `USER_SET` and `ONE_TIME` are the tell: a human answered a dialog, and chose a one-time grant. So the manifest `` plus wry's own bridging is sufficient, and **neither fork contemplated for this phase is needed** -- no Kotlin `requestPermissions` shim, no third-party permissions plugin. Note that because `ONE_TIME` grants lapse, `permissions.query` reads `prompt` again afterwards; that is correct Android behaviour, not a regression. #### Two expectations this file got wrong Recorded because the reasoning that produced them is still in the file above and should not be trusted the next time it is reused. **`BarcodeDetector` is present.** The prediction reasoned by analogy from WebView2, where the Shape Detection API is missing because WebView2 does not carry the component that provides it. The Android System WebView does carry it. jsQR is therefore a fallback on Android rather than the only path, and Beam's decode budget is the fast one, not the 50-100 ms jsQR case that set the 10 Hz default. **`showSaveFilePicker` is present.** `app/src/tauri-sink.js`'s header states it is "absent from every webview Tauri embeds", and that is now false for this one. It changes nothing in practice -- `app/src/main.js` calls `registerPlatform()` unconditionally, so the Tauri sink is used regardless, and that is the right outcome given the sink is the fast path on desktop. But the comment overstates its case and the platform seam is load-bearing for a reason that is no longer "the API does not exist here". #### The first real transfer found a CSP bug, not an Android bug The phone-to-desktop transfer failed twice before it worked, and the cause was on the **Windows** side and had nothing to do with Android: the generated CSP did not allow `http://ipc.localhost`, so Tauri's invoke fetch was refused, Tauri silently fell back to a JSON body, and `sink_write` rejected the first chunk. Fixed in `buildCSP(..., { ipc: true })`; the reasoning is in that function's comment and the commit. It is recorded here because of *how* it presented, which is the transferable lesson. The visible error was "the two devices lost track of each other" -- a message about the network, produced by a CSP directive, three layers from the cause. `receiver.js`'s `abortActive` nulls `active` on the first failure, and every chunk still in flight then throws `Chunk arrived with no accepted file`, each one overwriting the displayed message. The real error appeared once, four milliseconds before a storm of thirteen. It was recovered by hooking the element's `_fail` over CDP and reading the whole ordered sequence. `receiver.js` already anticipated the storm -- its comment names it -- but the fix made stray frames non-fatal without making the *first* error the one the user sees. That reporting gap is a real finding and was not addressed here; it was closed on 2026-09-12 (open work #3 below). Throughput measured on the same debug binary, writing 32 MiB per block size: 4.0 MB/s at 16 KiB, 20.7 at 256 KiB, 24.1 at 1 MiB. The shape matches the Phase 2 curve (small blocks starve on per-invoke cost, and it flattens after 256 KiB), and the absolute numbers are below Phase 2's 34/42 because this is an unoptimized debug build. At 24 MB/s the sink moves a 3 MB file in about a tenth of a second, so it is not what makes a transfer feel slow. #### The sink is broken on Android as a receiver, and this is what blocks that direction Confirmed rather than suspected. `save()` opens the Storage Access Framework picker and returns a **`content://` URI**, and `sink.rs`'s `sink_open` does `File::create(path)` on it: ``` sink_open FAILED -> No such file or directory (os error 2) ``` A receiver on Android therefore cannot write a file, which is why the LAN transfer row above is "not run" rather than failed: there is no point running it until this is fixed. Sending is unaffected, since the sender never opens a sink. The two candidate fixes are unchanged (write into the app's own documents directory and surface the path, or resolve the content URI Rust-side through Android's `ContentResolver`, which is what `plugin-fs` does). Choosing between them is a real decision about where files land on a phone, and belongs behind the existing platform seam either way. #### Resolved (2026-09-12): Android receives, and it took two fixes, not one Re-measured first on the F-Droid 1.0.0 build before touching anything, and the failure presented worse than recorded above: pressing Save created the document at **0 bytes** and the screen said *"The save dialog was closed without choosing a location"*. `receiver.js`'s `accept()` turns any `createSink` rejection into `null`, and `element.js` reports a `null` sink as a dismissal, so a person who pressed Save was told they had cancelled. A 0-byte `qrdrop-test-5MB.bin` already sat in Downloads from an earlier attempt that failed the same way. **Fix one, the content URI.** `sink_open` now opens through `plugin-fs`'s Rust API (`app.fs().open()`), which resolves a `content://` URI into a real file descriptor through the ContentResolver and is plain `std::fs` on desktop. It is `async`, because that open is a round-trip into plugin-fs's Kotlin half. Chosen over the documents directory because the file then lands where the person chose. Logcat on the device showed `Tauri plugin: pluginId: fs, command: getFileDescriptor` and the receive moved. **Fix two, which fix one uncovered: Android has no raw request body.** That run got to 1020 KB of a 2.9 MB file and failed at the first 1 MiB flush with `sink_write expects a raw body`. Tauri's IPC script sets `canUseCustomProtocol = osName !== 'android'` (the WebView's request interceptor cannot read a POST body), so every invoke arrives through `postMessage` as JSON, and `InvokeBody::Raw` says in its own doc comment that it is unsupported on Android. `sink_write` now also takes `{ data: }`, and `sink_open` returns which shape this target carries (`!cfg!(target_os = "android")`, the same condition Tauri keys on). Desktop keeps the raw path. `base64` 0.22 was already in `Cargo.lock` through Tauri; naming it added one line to the lockfile and no crate. Measured on the Realme RMX3868 (Android 16, WebView 151), debug build, CLI sender on the same LAN: | receive | result | | --- | --- | | 3 MB | 3,000,000 bytes, SHA-256 identical to the source | | 64 MiB | 67,108,864 bytes, SHA-256 identical, 19.6 s from Receiving to done: **~3.4 MB/s** (sending from the same phone was 4.03) | | 3 MB under an existing 5 MB name | a new `name (1)` document, byte-identical; the 5 MB original untouched, sender and phone digests equal | | cancel at 21% of 64 MiB | no crash, app back on choose, **partial file left at 15,910,050 bytes** (open work #3b; re-run on `a4f986c`: emptied to 0 bytes, no error sheet) | | Beam receive | re-run 2026-09-12 on a fresh install of debug `d1a5d19`, beaming a 60,000-byte random file from `/edge/` in a desktop browser on the Windows machine: the OS camera prompt appeared on first use, the offer sheet opened, Accept went straight to the save dialog, and the file landed in `Download/` at 60,000 bytes with SHA-256 identical to the source. MediaStore listed that same document at `_size=0` afterwards -- the DocumentsUI 0 B listing below, reproduced here without a network in the path, and fixed the same evening | | Windows regression, debug build (raw body) | 3 MB twice: once to a new file, once saved over an existing 5,000,000-byte file after Windows' replace prompt — 3,000,000 bytes, byte-identical, sender and app digests equal. `truncate` proven where the dialog does overwrite | Four things found on the way, none of them the sink: - **ColorOS freezes a backgrounded app while the save dialog is up, and a slow choice loses the sender.** First seen as `OAppNetControlService: Close socket:[10372] cause:App bg(IMMEDIATELY)` two seconds after the picker opened, with the peer gone before Save returned. Separated on 2026-09-12 with four timed runs, CLI sender to the phone on one LAN, Save tapped by `adb input tap` at a fixed time after DocumentsUI came to the front, and `OplusHansManager` logged throughout. The save dialog is its own task, so opening it is qrdrop leaving the screen, and Hans walks it through a fixed schedule: `R` for 6 s, `M` for 5 s, then `F` -- `freeze uid: 10376 pids: [...]` and the socket close in the same millisecond, about 11 s after the dialog opened, every run. | whitelist | time in dialog | what Hans did | result | | --- | --- | --- | --- | | off | 2.2 s | `R` for 2 s, never frozen | received, byte-identical | | off | 20.2 s | frozen 9 s, sockets closed | **received**, byte-identical | | off | 45.2 s | frozen 34 s, sockets closed | **failed**: sender gave up at 24 s | | on (`deviceidle`) | 45.1 s | `F` every 5 s, thawed each time within ~1 s by `reason=Packet`, no socket close | received, byte-identical | So neither variable alone is the answer. The socket close is not what breaks the transfer -- it takes the relay WebSockets (`ERR_CONNECTION_ABORTED` on all four at the thaw) and leaves a direct UDP peer connection alone. What breaks it is a freeze longer than the sender's ICE consent timeout: libdatachannel v0.24.3 pins libjuice at `22145ec`, where `CONSENT_TIMEOUT` is 30,000 ms with checks every 4-6 s, and the sender printed *"The other device disconnected"* 24.3 s into the freeze, about ten seconds before the phone thawed. The whitelist works because a whitelisted app is thawed by its incoming packets, so it answers consent checks, not because the freeze is skipped. The first failure, closed at two seconds, is consistent with an app that had already spent its `R` and `M` time in the background before the picker. A relayed (TCP) connection would be cut by the socket close itself; not measured. Vendor behaviour outside the WebView, still not worked around, but **avoided since 2026-10-17**: the app no longer opens a save dialog at Accept. A receive goes to `Download/qrdrop` on Android 10+ through MediaStore, or to a folder granted once, asked for on the Receive tap before any pairing exists (`src-tauri/src/storage.rs`, `StoragePlugin.kt`). The only picker left opens with no peer to lose. The failure it exposed on our side is recorded below. - ~~**A receive whose sender left during the save dialog stood on "Receiving, 0%" forever.**~~ **Fixed 2026-09-12.** The 45 s run above never showed a failure. On the thaw the phone logged *"The other device disconnected"*, opened the error sheet, and 400 ms later drew the transfer screen over it -- `accept()` had come back with the file, and a screen change clears a modal -- then sat at 0% with an empty status and only Cancel, with `item4-3mb.bin (2)` open at 0 bytes, until the harness gave up 120 s later. The web receiver kept `_establish`'s leave handler, which only opens the sheet; the sender had the same flaw until `e8aaf50`, and the command-line receiver never had it. `_startReceive` now replaces that handler, as `_startSend` does: after `receiver.settled()`, on `verify` or `transfer` only, it fails the transfer, which closes the room and cancels the receiver, and Accept stops if the session ended while the dialog was up rather than reading the released file's `null` as a dismissal. Rerun on a debug build with the fix, same 45 s and no whitelist: 1.1 s after the thaw the phone was on "Transfer failed" with *"The other device disconnected"* in the sheet, and the saved document was emptied to 0 bytes. - **The Downloads provider never overwrites from a create-document picker.** Saving under an existing name produced `overwrite-test.bin (1)`. So `truncate` (the `"wt"` mode) is correct and unreachable through this dialog on Android — unverified on a device, not proven. - ~~**DocumentsUI showed the received files as 0 B**~~ **Fixed 2026-09-12.** `ls` gave their true sizes the whole time. The Beam receive run showed it from the other side: `content query --uri content://media/external/file` returned the new `Download/beam-60k.bin` with `_size=0` while the file on disk was 60,000 bytes and byte-identical, so it is the save path's and not the network receive's. The cause is the handover, not a slow index: plugin-fs's Kotlin half opens the URI and calls `detachFd()`, MediaProvider wraps every write open in a close listener that rescans, and `ParcelFileDescriptor` reports a detach to that listener immediately, as if it were a close. So the scan ran at `sink_open` on a file `"wt"` had just emptied. The row's `date_modified` was the second of the open, this process logged `Peer expected signal when closed; unable to deliver after detach` in that same second, and the row still said 0 ten minutes later. `sink_close` now reopens a content URI in `"wa"` and drops it -- write, because MediaProvider only listens on write opens, and append, because the mode without it may truncate. Measured on a debug build: a 3,000,000-byte file saved through the same four invokes a receive makes was indexed at `_size=3000000`, `date_modified` the second of the close, SHA-256 identical, and the close including the reopen took 34 ms. - **One pairing failed with `Out-of-order frame: expected 0, got 1`** before any offer appeared, and the next identical attempt did not. Chased the same day, and **not a device problem**: every control message took its index, awaited the seal, then sent, so the CLI's path verdict and manifest -- sent back to back -- could reach the wire in either order. Reproduced deterministically by slowing the first seal, and fixed in `src/core/control.js`'s `sendControl` (CLAUDE.md, "Control frames in one direction leave in index order"). The same message then appeared **a second way**, live between two CLIs while checking the cancel fixes: `room.js` dropped frames from the paired peer that arrived before `onFrame()` was registered, and the CLI registers only after its SAS prompt, so a peer confirmed first lost its path verdict (index 0). Early frames from the paired peer are now held, bounded. The phone's run was the first cause, not this one — the website's receiver registers its handler straight after pairing. That last row is worth stating plainly rather than discovering: until `assetlinks.json` carries a real signing-certificate fingerprint, a scanned QR on Android opens `share.stan-ely.com` in the browser rather than the installed app. That is exactly the behaviour Phase 3 verified end to end and is fine -- but it means "receive by scanning into the native app" is not testable on Android this phase. Sending, and receiving via a pasted code, are. #### The transfer was slow for a reason that had nothing to do with the network The first working Android send moved 3 MB in 30.2 s -- 102 KB/s, against the 9.5 MB/s Phase 2 measured over loopback. It felt like a slow link and was not. Instrumenting the sender's inner loop at prototype level over CDP (`Blob.prototype.arrayBuffer`, `crypto.subtle.encrypt`, `RTCDataChannel.prototype.send`) split the 30.3 s window cleanly: | stage | calls | total | avg | share | | --- | --- | --- | --- | --- | | `slice().arrayBuffer()` | 192 | **29,723 ms** | 154.81 ms | **98.2%** | | `crypto.subtle.encrypt` | 202 | 21 ms | 0.10 ms | 0.07% | | `RTCDataChannel.send` | 393 | 9 ms | 0.02 ms | 0.03% | A `File` the Storage Access Framework returns is backed by a `content://` provider, so each read is a Binder round-trip. The control that settles it: the same page, the same 16 KiB read pattern, the same instrumentation, against an in-memory `Blob` instead -- **0.98 ms** a read against **154.81 ms**, a factor of 157. It is the backing store, not `Blob.slice`, not `arrayBuffer()`, and not the probe. The cost is dominated by the call, not the byte count. Sweeping block size on a real picked file, reading 32 MiB per row out of 64 MB: | block | ms per read | throughput | reads for 64 MiB | read time | | --- | --- | --- | --- | --- | | 256 KiB | 80.2 | 3.12 MB/s | 256 | 20.5 s | | 1 MiB | 87.4 | 11.44 MB/s | 64 | 5.6 s | | **2 MiB** | 97.6 | 20.49 MB/s | 32 | **3.1 s** | | 4 MiB | 114.2 | 35.03 MB/s | 16 | 1.8 s | | 8 MiB | 126.3 | 63.33 MB/s | 8 | 1.0 s | | 16 MiB | 138.3 | 115.69 MB/s | 4 | 0.6 s | That fits **~79 ms fixed + ~3.7 ms per MiB**. An earlier pass measured this against the 3 MB file only and read the curve as perfectly flat -- which is how it looks at that size, because the fixed term dominates until around 21 MiB. The flat reading argues for any block size at all; the real curve does not. Measure the sweep on a file big enough to show the slope before touching the constant. `fromFile` now reads 2 MiB blocks and serves frames out of them. 2 MiB is where the fixed term still dominates (81% of a 2 MiB read is overhead, so the block is bought cheaply) while halving the read count against 1 MiB, and the payoff is largest on SMALL transfers -- the common case here. | | before | 3 MB | 64 MiB | | --- | --- | --- | --- | | transfer time | 30.2 s (3 MB) | **3.34 s** | **23.56 s** | | throughput | 102 KB/s | 0.9 MB/s | **2.72 MB/s** | | `arrayBuffer()` calls | 192 | 4 | **33** | | time in reads | 29,723 ms (98%) | 1,306 ms (38%) | 5,988 ms (25%) | The 64 MiB run would have taken about **eleven minutes** on the per-frame read. Digest verified both times. Three wrong turns are worth recording, because each was taken with confidence. The first hypothesis was that unthrottled `_setState` per frame was the bottleneck; measuring it returned 1.2 ms a render, 0.2 s of 27.9 s, and it was abandoned. The second was that 1 MiB would take reads out of contention entirely, on the strength of an idle sweep's 98 ms -- under concurrent load the same read costs ~181-326 ms, so the idle benchmark understated it two- to threefold. The third was reading a 3 MB sweep as proof the cost was purely per-call. The ~25% still spent reading is **not** a case for a bigger block. `sender.js` awaits `slice()` before it seals, so the channel is idle for every read; raising BLOCK_BYTES makes those gaps fewer and longer for double the memory each time. Prefetching the next block while the current one transmits is what removes them, and is the next thing worth doing here. #### It was not a Tauri bug: the website had it too Worth running before concluding this was about wry, and the answer changes who was affected. The deployed site was served to the same phone over `adb reverse` and opened in **Chrome 152**, and the same file was picked through the same SAF dialog and swept the same way: | block | Chrome ms/read | Chrome | wry ms/read | | --- | --- | --- | --- | | **16 KiB** | **71.72** | **0.22 MB/s** | ~86 | | 256 KiB | 77.85 | 3.21 MB/s | 80.2 | | 1 MiB | 80.68 | 12.40 MB/s | 87.4 | | 2 MiB | 85.37 | 23.43 MB/s | 97.6 | | 4 MiB | 86.38 | 46.31 MB/s | 114.2 | | 8 MiB | 98.10 | 81.55 MB/s | 126.3 | | 16 MiB | 100.90 | 158.57 MB/s | 138.3 | Chrome fits ~72 ms fixed + ~1.8 ms per MiB against wry's ~79 + ~3.7 -- slightly better on both terms, identical in kind. **Chrome does not copy the picked file into its own cache**; it passes the `content://` through exactly as wry does. So `share.stan-ely.com` had this for every Android user who ever sent a file, at **0.22 MB/s** on the 16 KiB frame it actually used -- a 64 MiB send would have spent about nine minutes in reads alone. Desktop browsers were unaffected because a `File` there is backed by a real filesystem. That is also the whole explanation for how it survived this long. The sender was always a desktop browser, WebView2, or the Node CLI -- and the CLI uses `src/node/source.js`, a different adapter with a file descriptor and a reused buffer, so it could not have shown this even in principle. No test in the suite uses a real picked file, because there is no way to; the unit tests read in-memory `Blob`s at 0.98 ms. An Android device as *sender* was the one configuration nobody had run, and it is the only one that could have caught it. The fix lives in `src/web/source.js` and so ships to the site and to `qrdrop/web`, not only to the app. Two suspects were cleared in the process. Trystero's backpressure gate never fired at all (`bufferedAmount` peaked at 16,620 against a 65,535 threshold, and `waitForBufferedAmountLow` was reached zero times -- still true at 64 MiB, where it peaked at 49,353). And the transport *was* splitting every frame into two SCTP messages -- 393 sends for 192 frames, confirming a 66-byte overshoot of the action wire's 16348-byte budget -- but that cost 9 ms and was fixed on its own merits, not as a remedy for this. After the fix the ratio is 1.00 across 4,122 sends. Everything else is now noise: at 64 MiB, AEAD is 344 ms over 4,123 seals (1.5%) and `RTCDataChannel.send` is 97 ms over 4,122 calls (0.4%). #### The read-ahead, and what it is not yet proven to be worth The blocks left reads at **25% of the 64 MiB window**, and that residue is a different problem from the one they solved. It is not that the reads are expensive -- 33 of them is already near the floor -- it is that they are *serialised against the sends*. `sender.js` awaits `slice()` before it seals, so at each block boundary the data channel goes quiet for a whole Binder round-trip and then resumes. Making the block bigger does not fix that shape; it makes the silences fewer and longer and doubles the buffer each time. `fromFile` now issues the next block's read as soon as the current one lands, so the round-trip runs alongside the ~128 frames the current block still has to transmit. Two structural changes came with it, and both are the kind that would be quietly undone by someone tidying: - **A frame straddling a boundary is stitched from both blocks** rather than refilling from its own offset. This looks like a micro-optimisation and is not. `CHUNK_SIZE` (16318) divides no power of two, so *every* boundary lands mid-frame; the previous refill-from-here behaviour therefore meant the prefetched window -- aligned to the block -- was never the window asked for. A read-ahead discarded at every boundary is not a read-ahead, and the straddle is what makes the whole thing work rather than a tidy extra. - **Peak memory is two blocks, 4 MiB, not one.** That is the price, it is paid on the device least able to afford it, and it is the reason `BLOCK_BYTES` stays at 2 MiB rather than growing. `test/web-source.test.mjs` pins it from Node: eight tests over a File-shaped stub that counts reads and records their offsets, asserting one read per block (not one per frame, and not one per block plus a straddle), that the read for block N+1 is already in flight after the first frame of block N, and that a read-ahead which *fails* is retried by the call that eventually wants those bytes rather than rejecting into nowhere. Measured on the device, same rig as the baseline in every respect that could move the number: the same 64 MiB `qrdrop-large.bin`, the same phone, the same debug APK and debug Windows receiver, both sides rebuilt from the same commit. | | blocks only | + read-ahead | | --- | --- | --- | | chunk-loop window | 23.56 s | **16.80 s** | | throughput | 2.72 MB/s | **4.03 MB/s** | | reads | 33 | 32 | | frames per read | ~125 | 128.6 | | AEAD | 344 ms (1.5%) | 350 ms (2.1%) | | `dc.send` | 97 ms (0.4%) | 100 ms (0.6%) | **29% off the wall time, 1.48x the throughput**, digests matching both ends. The window is the chunk loop, not the session: the manifest goes out long before the receiver's human has accepted and chosen a destination, and this run spent 31 s and 10 s on exactly those two waits. Both baselines were taken the same way, so they compare. Two things about that result are worth more than the headline, and both cut against the obvious reading. **Total read time went UP, and that is the mechanism working.** 7,475 ms over 32 reads (234 ms each) against 5,988 ms over 33 (181 ms each) -- a read now runs against a busy main thread and a full send buffer instead of in the silence it used to create for itself. Per-read wall time rising while the transfer gets a third faster is what overlap looks like from outside. It also means "time in reads as a share of the window" -- the 25% this section quoted before -- is no longer a cost at all, and quoting it as one after this change would be wrong: it is now 44.5%, on a transfer that finished sooner. **The split underneath the total needed no new instrumentation after all, and the answer is that reads now cost the transfer nothing.** The first pass here said the split was unmeasurable from outside `fromFile`, because the probe attributes any send-silence overlapping a read to that read while `drain()` and a read are concurrent by design -- giving 5,638 ms as an upper bound that counts backpressure as read cost. That was true of the *attribution*, and wrong as a conclusion about the data, which already contained the answer. `sender.js` walks the file in order, so block k is first wanted at the instant the last frame of block k-1 goes out, and both timestamps are in the timeline: the read intervals, and the send that precedes each boundary. Waiting time is therefore `max(0, readEnd - needAt)` per block, and it comes out as: | blocks | read-ahead late | time blocked on a read | | --- | --- | --- | | 31 boundaries | **0** | **0 ms (0.0%)** | Every read-ahead finished before it was wanted, with 160-440 ms of margin on every one of the 31 boundaries. The margins are an order of magnitude larger than the estimator's own error (control frames shift `needAt` by a few frames at ~4 ms each), so the conclusion is not delicate. The 5,638 ms "read stall" was backpressure that happened to coincide with a read, exactly as the caveat suspected -- and the loose bound was 5,638 ms against a true value of zero, which is a fair warning about how far an upper bound of that shape can sit from the answer. **The transport is the bottleneck now.** 7,275 ms of the window is silence with no read outstanding at all; `bufferedAmount` peaked at 58,734 against the 65,535 threshold; and the sustained rate is 4.03 MB/s against a p95 of 4.89 over 500 ms buckets. Even a read path that cost literally nothing -- which is what it now costs -- leaves the transfer where it is. One incidental note in the result's favour: the Windows receiver spent this entire transfer decoding a live camera stream, because of the bug below. That can only have cost throughput, so 16.80 s is if anything pessimistic. **A correction to the CHUNK_SIZE story, from the same data.** The frame budget in `src/core/frame.js` is built on "one SCTP message is 16 KiB and the action wire spends 36 bytes of it", and the second half of that is not what the wire does. This run sent 67,657,281 bytes over 4,122 calls: **16,413.7 bytes per message against a 16,348-byte sealed frame, so the per-message overhead is ~66 bytes, not 36.** The emitted message is therefore ~16,414 bytes -- over 16 KiB, and accepted, because the negotiated SCTP `maxMessageSize` is far larger than the 16 KiB the comment treats as a hard ceiling. The fix's *goal* is untouched and confirmed: 4,122 sends for 4,113 chunks is 1.002 per frame, so a sealed frame still travels as exactly one message, which is the whole point of the constant. What is wrong is the stated mechanism -- 36 is trystero's payload-chunking constant, not its header size, and the two were conflated. Anyone re-deriving CHUNK_SIZE from the comment would get the right answer for a reason that does not hold. #### The webcam stayed on, and only a human could have caught it Found by the light on the receiving laptop, not by any check in this repository: the Windows receiver held its camera open for the whole 64 MiB transfer and after it, long after the scanner screen was gone. The cause is a lifecycle gap in `src/web/qr.js`, and it is a webview-agnostic bug in shared code rather than anything about Tauri or Android. `scanQRStream` checks `signal.aborted` before opening the camera, then registers its abort listener three awaits later -- after `getUserMedia`, `video.play()` and `createDetector()`. An abort landing in that window finds no listener, and since a signal that has already fired never fires again, the scan promise never settles, its `finally` never runs, and the track is never stopped. The path in is ordinary. The hand-entered code field lives *on* the scanner screen, and `element.js`'s `_submitManualCode` calls `_teardown()`, which aborts that very signal -- so pairing by pasted code rather than by scanning aborts mid-open every time. `getUserMedia` also spans the OS permission prompt on a first run, which widens the window from milliseconds to however long the person takes to decide. Two lessons, both about what a test suite can see. The function's own doc comment already promised the camera is "always" stopped "including on error and on cancel", so the contract was stated and the code silently disagreed with it -- prose is not a check. And nothing headless can observe a camera that was never released: the transfer completed, the digests matched, and every assertion in the suite passed while the hardware stayed on. `test/qr.test.mjs` now asserts the release directly, including that the call *settles* -- the old failure was a promise that hung forever, so a test checking only the track would have hung with it rather than failing. Verified against the unfixed file, where it fails. ### On-device checklist -- iOS No Apple hardware exists on this project, so this table stays empty until someone with a device runs it. CI proves the target links; it cannot prove any row below. | Check | Expectation | Result | | --- | --- | --- | | `isSecureContext` | pass -- `tauri://localhost` | | | `crypto.subtle` | pass | | | `showSaveFilePicker` | **absent** -- native sink | | | `BarcodeDetector` | present on iOS 17+ | | | `getUserMedia` + TCC prompt | pass -- `Info.ios.plist` carries `NSCameraUsageDescription`, staged in Phase 3 and merged natively by Tauri, so no `gen/apple` edit is needed | | | `RTCPeerConnection` + ICE | pass -- Phase 0's macOS row found WKWebView has it, unlike WebKitGTK | | | full LAN transfer, digests match | pass | | | Beam send / receive at ~10 Hz | pass | | ### The verdict on mobile Beam and WebRTC, stated as expectation The onboarding brief asks Phase 4 to decide honestly whether mobile Beam is achievable. The honest answer today is **"expected on both platforms, proven on neither"**, and the two platforms are expected for different reasons. **Android is now measured, not expected, and the answer is yes on capability.** Every primitive Beam and the WebRTC transfer need is present and working on a real device: `RTCPeerConnection` with a host candidate, `wss://` to a relay, WebCrypto, a live camera behind a real OS permission prompt, and a native `BarcodeDetector` that makes decode cheaper than the jsQR budget the 10 Hz default was sized against. Nothing here is capped by the webview. What was *not* demonstrated when this was first written was a completed receive, and the reason was a bug in this project rather than a limit of the platform: the native sink could not open the `content://` URI Android hands back (above). **Since closed, 2026-09-12:** a 64 MiB network receive byte-identical and a Beam receive through the camera, both on the Realme, with the fixes on `main` and not in 1.0.0 (see "Resolved" and "Open work carried forward" below). Beam send and the deep-link paths are still not run. So the status is "network transfer proven both ways, Beam proven receiving", and what is left is work, not a platform decision like the Linux one. The one platform-side caveat found on the way is ColorOS freezing the app behind the save dialog, which is vendor behaviour and recorded below. **iOS** inherits Phase 0's macOS finding: WKWebView has WebRTC with an immediate host candidate, and the two WebKit-family webviews are not equivalent. With `BarcodeDetector` present on iOS 17+, Beam should decode faster there than anywhere else. If Android's camera cannot be made to prompt and neither fork above is worth its cost, the fallback needs no code: `choose()` in `src/web/view.js` already hides the network buttons when `!rtcAvailable` and refuses Beam receive when `!cameraAvailable()`. The UI already tells the truth about a platform that cannot do something, which is why that seam was built before it was needed. Record which mode Android actually ships in. ## Phase 5: releasing the app The shell worked and there was no way to get it. No release workflow, no changelog, no version -- the crate was still `0.0.0` -- and no icon anyone had chosen: the desktop bundle carried a placeholder that commit `85cfe27` names as such in its own subject, and the Android launcher showed Tauri's scaffold logo. ### `app.yml`'s first real run, which is what the Phase 4 gate was waiting for Item 5 below was "the workflow is committed but unpushed, so all three jobs are unproven". Pushed, and two of them were wrong. Both failures are worth keeping, because both were invisible to every local check. **The iOS job could not have worked as written.** It drove `xcodebuild` against the generated Xcode project, and that project's "Build Rust Code" phase shells out to `tauri ios xcode-script`, which panics: ``` failed to read missing addr file $TMPDIR/com.stan-ely.qrdrop-server-addr ``` The tauri CLI writes that file just before *it* invokes xcodebuild. Running xcodebuild ourselves skips the step that creates it, so the path can never exist, and the whole thing surfaces as a bare `exit 65` with nothing in it that points at a cause. The first fix guessed that `debug` was what put the script into dev mode and switched to `release`. It failed identically -- which is the useful part of the attempt: the configuration is not the variable, and a plausible reading of that panic was simply wrong. The supported route is `tauri ios build`, which archives and exports and wants a development team even for the `debugging` export method, which is exactly why the job avoided it in the first place. So the job now runs `cargo build --lib --target aarch64-apple-ios-sim`, and the claim it makes is smaller and true: the crate, its plugins and both mobile crate types still compile and link for an Apple target. It does not cover the Swift shell -- generated code nothing in this repo edits, and which the `tauri ios init` step above it already proves regenerates. Claiming less and passing is worth more than claiming to link the whole app and failing on every push. **The Android job broke on a lesson already written down here.** XML forbids the two-hyphen sequence inside a comment; the house prose style reaches for it as an em dash constantly; and the new adaptive-icon background file carried two. Android's resource merger reports it with a line number this time, but the manifest merger's version of the same failure has none. Second occurrence. The comment in that file now says so, in prose containing no such sequence. Everything else was green first time: three desktop bundles, and the Android APK including the camera-permission assertion. ### The app mark `scripts/make-icon.mjs` renders a 1024px source from `tokensCSS(':root')`, and `npx tauri icon` fans it out. Hand-run and committed, for the reason `make-og.mjs` states: `npm run build` runs in CI and in `prepublishOnly`, and neither may download a browser. The mark is the three finder patterns of a QR code. The wordmark could not be it -- it is pure CSS type with no logo file behind it, and type at the 48px an Android launcher draws is mud. The finder patterns are the part of a code that survives being shrunk, which is why a scanner looks for them first. It is not a scannable code: `make-og.mjs` argues a decorative fake QR is a small lie, and that argument is about something *claiming* to be scannable. Three eyes claim nothing. **One measurement rather than a taste judgement.** The mark occupies the middle 47% of the canvas. `tauri icon` emits the same image as Android's adaptive-icon foreground, a launcher masks that to the central 72 of 108 dp -- 66.7% -- and the largest square inside that circle is 0.667 / sqrt(2) = 47.1% of the width. The first render used 64%, which looks correct in a file browser and puts the top-left eye's corner outside the mask on any phone with round icons. Two hand-edits to the committed `gen/` tree came with it, which is the second and third use of the arrangement Phase 4 set up: the adaptive background colour, which `tauri icon` rewrites to `#fff` on every run, and the deletion of the two scaffold drawables the new `anydpi-v26` icon no longer references. The site had no favicon at all -- no `` in `index.html` -- so a tab, a bookmark and a phone home screen all showed a blank sheet for a tool whose subject is a recognisable square. It is the same mark from the same script, drawn at 512 rather than downsampled from 1024, because a border and two nested radii round differently when the layout engine resolves them at the target size than when a resampler averages them down. ### Signing, and assetlinks finally going live A 4096-bit RSA release keystore now exists. It is not in the repository and never will be: its base64 is a repository secret, and `app-release.yml` writes it and a `keystore.properties` into the runner for the length of one job. `gen/android/app/build.gradle.kts` reads that file *if it exists* and leaves the release build unsigned if it does not, which is what keeps a keyless local `mise run app:android:build` working -- and is also precisely how an unsigned APK could reach a Release with nothing failing, so the workflow runs `apksigner verify --print-certs` rather than trusting the arrangement. It deliberately does not fall back to the debug key. A release APK signed with a per-machine debug certificate installs, looks fine, and attests to nothing, and its fingerprint can never go in `assetlinks.json`. The certificate's SHA-256 is a repository **variable**, not a secret: a fingerprint is published by design at a well-known URL on the very domain it describes, and storing a public value as a secret teaches that the secret list is where things go to feel safe. `build-site.mjs`'s `wellKnownFiles` has read `ANDROID_CERT_FINGERPRINT` since Phase 3 and has been emitting an empty array -- which Android reads as "no app claims this domain" -- because no key existed. Setting the variable turns it on with no code change, which is the seam that function was written for. **One thing that does not follow from that, and would have been assumed.** `pages.yml` builds both trees, but the stable one at `/` comes from the latest `v*` tag, and a tag cut before `wellKnownFiles` existed emits no association files at all. Android fetches them from the domain root. So association goes live at `/edge/` immediately and at `/` only on the next npm release -- setting the variable is necessary and, for the stable site, not yet sufficient. ### What the release ships, and what it admits `app-release.yml` on `app-v*`. The tag prefix is load-bearing: a workflow's `tags:` glob is anchored at the start, so `v*` matches `v0.3.1` and does not match `app-v0.1.0`, which is what lets the npm release and the app release share a repository without racing. `0.1.0-app` would not have. The version gate and the changelog check run first and cost seconds, so a tag that disagrees with `Cargo.toml` fails while it can still be deleted rather than after a Release page exists. `cargo-release` (`app/src-tauri/release.toml`) produces the tag with `push = false`, so a human looks at it first. Only the APK is signed. macOS will quarantine the `.dmg` and SmartScreen will warn about the installer, and the Release body says so with the incantation for each rather than letting someone meet it cold. That is not politeness: a tool whose entire subject is authenticating the other end is the worst possible place to train people to click through a publisher warning. Build provenance attestations go on every file, which is a verifiable claim about origin and not a code signature, and the notes do not offer it as one. ### Gate **Config-lands gate (this phase):** `.github/workflows/app.yml` green on all three jobs, `mise run typecheck` and `mise run test` clean, and `mise run app:config` producing no unexpected `tauri.conf.json` diff. **On-device verification is explicitly open and does not block Phase 5.** It completes when the checklists above are filled in from a real phone, including a full LAN transfer with matching digests and a Beam receive, and when the `gen/android` re-init cycle (`init` over a committed tree must not silently drop the camera permission -- CI asserts this once the tree is tracked) has been exercised once by hand. ### Open work carried forward Named here so it is a list rather than a set of remarks buried in the sections above. None of it blocks the config gate. 1. **The transport, which is now the bottleneck.** Reads are settled and cost zero (above); the send path is not. 7,275 ms of a 16.80 s window is silence with nothing outstanding but the channel, `bufferedAmount` peaks at 58,734 against a 65,535 threshold, and the sustained 4.03 MB/s sits under a 4.89 p95. Nothing further on the read side can move this. Before treating it as a qrdrop problem, establish what a bare `RTCDataChannel` does between these two devices with no framing, sealing or sink in the path -- Phase 2's 9.5 MB/s was loopback on one machine and is not that number. **Partly answered (2026-09-13 and 2026-09-26)**, for a bare channel on one machine with a controlled delay; see "The bare transport, measured" below. What a data channel can do is set by SCTP congestion control and the round trip, not by anything qrdrop or Trystero chooses. **The two-device number (2026-09-27)**: a bare channel with the phone sending to a desktop Chromium over the same Wi-Fi ran 4.4-4.9 MB/s. So the app's 4.03 is about 90% of what the channel itself carries between these two devices, and the rest of the cost is not worth chasing. See "The Android WebView, on the phone" below. 2. ~~**The `content://` sink**, so Android can receive at all.~~ **Closed 2026-09-12.** It took the content-URI open *and* a base64 body for `sink_write`; see "Resolved (2026-09-12)" above for both and the numbers. 3. ~~**First-error-versus-last-error reporting.**~~ **Closed 2026-09-12.** `abortActive` nulled `active` on the first failure and the in-flight chunks then overwrote the message the user sees with `Chunk arrived with no accepted file`; the base64 run's real error (`sink_write expects a raw body`) was followed by 119 of them and a closing `Unexpected completion`. The receiver now stops at its first fatal failure and skips every frame queued behind it. A unit test drives a sink that cannot write through a nine-chunk transfer and counted ten reports before the change, one after. 3b. ~~**Cancelling a network receive never aborts the sink.**~~ **Closed 2026-09-12, and checked on the device** (debug build of `a4f986c`, same Realme): Cancel at 25% of a 64 MiB receive took the document from 14,849,380 bytes to 0 within five seconds, and the app went straight back to choose with no error sheet. `_teardown` on that path was `room.close()` alone, which left 15,910,050 bytes of a cancelled 64 MiB receive and the native handle in `SinkState`. `createReceiver` now has a `cancel()` that aborts the open sink, and `element.js`'s teardown runs it before closing the room. On Android `sink_abort` can empty a `content://` document and cannot delete it, so a cancelled receive there leaves an empty file under the chosen name. The CLI got the same treatment: a sender leaving mid-file, or Ctrl-C, now deletes the partial file. The first of those is checked live, CLI to CLI on one Windows machine: the sender was killed with 41,170,314 bytes of a 64 MiB file on disk, the receiver reported the disconnect, and the output directory was empty afterwards. Ctrl-C was then pressed by hand, in a real terminal on the same machine: a 2 GiB receive interrupted at 336 MB printed "Cancelled." and left the output directory empty. The sender went on to "Sent 340 MB" and then reported "The other device disconnected" and exited 1, about 11 s later. That delay was the connection timing out rather than being told: the SIGINT handler ran the interrupt hooks and exited without closing the room, so no leave message went out. Both commands now put `room.close()` among those hooks, and `close()` returns Trystero's `leave()`, which sends the leave message and waits 99 ms for it to go out. Pressed by hand again: interrupted at 167 MB, output directory empty, and the sender reported the disconnect 89 ms after its last progress line. 3c. ~~**A sink failure is reported as the person dismissing the save dialog.**~~ **Closed 2026-09-12.** `accept()` caught every `createSink` rejection as a dismissal. Now a sink resolves `null` for a dismissed dialog and rejects only for a failure, which reaches the screen as itself and the sender as `Peer refused the transfer: `. `web/sink.js` maps `showSaveFilePicker`'s `AbortError` to `null` to keep that contract, and Beam's Accept, which never caught a sink rejection at all, reports it too. 3d. ~~**A sender whose receiver leaves mid-file hangs.**~~ **Closed 2026-09-12.** Found in the 3b device run: the CLI sender went on to "Sent 64 MB of 64 MB" through 3,012 Trystero `no peer with id … found` warnings, and was still running two minutes later. `runSend`'s `onPeerLeave` calls `control.fail()`, which rejects only a waiter that already exists, and mid-file there is none -- so the leave is forgotten and `sendFile` then waits on a `done` that is never coming. The website's sender had the same shape, worse: its leave handler only opened the error sheet, so the send ran on under it and the room was never closed. Now a control stream's failure is sticky -- every later wait and the flow gate see it, and `sendFile` checks it between chunks -- and the website's sender fails its stream on a leave as well as saying so. Checked live, CLI to CLI on one Windows machine: the receiver process was killed at 11 MB of a 64 MiB send, and the sender stopped at 13 MB, printed "The other device disconnected" and exited 1, with no `no peer` warnings. That took ~21 s, which is the transport noticing a killed process that never closed, not the loop. The website sender is unit-covered and not run live. Also checked on the device the same day: the phone sending to a CLI receiver that confirmed its SAS 3.2 s after the phone did -- the order `25818cf` fixed -- delivered 3,000,000 bytes byte-identical with both digests equal. 4. **The unrun checklist rows**: Beam send and the deep-link `qrdrop:` path on Android. Beam receive was run on 2026-09-12 and passed: a 60 KB file beamed from a desktop browser's screen landed byte-identical. 5. ~~**`app.yml` has never run.**~~ **Closed.** Pushed and green on all five jobs (run `34038984977`): three desktop bundles, the Android APK with its camera-permission assertion, and iOS. It took three runs to get there and the two failures are written up in the Phase 5 section above -- the iOS job could not have worked as written, and the Android one broke on the two-hyphen XML rule this file had already recorded once. 6. ~~**`app-release.yml` has never run either.**~~ **Closed.** `app-v0.1.0` is cut and every job passed first time: the version and changelog gates, three desktop bundles, the signed APK, and the Release. It carries `app-universal-release.apk` (45 MB), `qrdrop_0.1.0_x64-setup.exe`, `qrdrop_0.1.0_amd64.deb`, `qrdrop_0.1.0_amd64.AppImage`, `qrdrop_0.1.0_aarch64.dmg` and `SHA256SUMS`. 6b. **A `v*` tag deploys the site green and does not publish it.** Found cutting `v0.4.0`, and it is two faults. The `github-pages` environment permitted only `branch: main`, so the deploy was refused outright -- a repository setting rather than anything in this repo, fixed by adding a `v*` tag policy. With that in place the rerun reported success, the uploaded artifact provably contained the new root tree (downloaded and checked), and the live site went on serving the previous main-triggered deployment for fifteen minutes: `Last-Modified` frozen at the earlier deploy on a response with `Age: 0`, so the origin and not a cache. A `workflow_dispatch` on main published the identical content at once. Unexplained, and worth one more look on the next release. It does not strand the site -- any push to main rebuilds stable from `git describe`, which finds the newest tag -- but "cut a release and the site updates itself" is not true today. 7. **App-link association: fixed twice, and still not live at the root.** Setting the fingerprint was necessary and, it turned out, nowhere near sufficient. Checking the served URL rather than the built tree found two independent faults, either of which alone would have produced the same symptom -- a scanned link opening the browser, with no diagnostic anywhere. (a) **The deploy was not serving the file.** `upload-pages-artifact` v4 stopped including hidden files, and `.well-known/` is one. Measured live, from a single build: `/edge/apple-app-site-association` returned 200 and `/edge/.well-known/assetlinks.json` returned 404. Android reads assetlinks only from the dotted path and has no root fallback. The comment directly above that upload step had predicted this exact failure, named `.well-known/` as the likely cause, and named `include-hidden-files: true` as the fix -- and it sat there for two phases, because a prediction in a comment is not a check. (b) **The package name was wrong.** The file published `com.stan-ely.qrdrop`; the APK is `com.stan_ely.qrdrop`, because an Android package name is a Java package name and cannot contain a hyphen. The test covering it asserted the hyphenated value, so the suite agreed with the bug -- it pinned `build-site.mjs` against a constant in `build-site.mjs`. It now reads `applicationId` out of `build.gradle.kts` instead. **Both fixed, and now live at the domain root**, which is what Android actually reads. `https://share.stan-ely.com/.well-known/assetlinks.json` returns the real fingerprint against `com.stan_ely.qrdrop`; the root `apple-app-site-association` serves too, inert without an Apple team. That took cutting `v0.4.0` -- `/` is built from the latest `v*` tag, and the previous one predated `wellKnownFiles` entirely -- and then a manual dispatch, per 6b above. **Verified on a device (2026-09-08), and the answer is yes.** A release-signed 0.1.0 installed on the Realme RMX3868 (Android 16) reports `share.stan-ely.com: verified` in `pm get-app-links`, and `pm get-app-link-owners` resolves the domain to `VERIFIED[4]: com.stan_ely.qrdrop`. Firing the real thing -- `am start -a android.intent.action.VIEW -d 'https://share.stan-ely.com/#qrdrop:'` -- brings `com.stan_ely.qrdrop/.MainActivity` to the foreground rather than a browser. The chain published in 6b is therefore correct end to end. **What had been hiding it is worth keeping, because it is not a qrdrop bug and it will happen again.** The phone was carrying a locally built debug APK, signed `3C:EE:D5:54...D2:55:4E:2E`, and while that was installed the same dump read `share.stan-ely.com: 1024` -- unverified. That is correct behaviour: the debug certificate is not one of the fingerprints in `assetlinks.json`, so association legitimately fails. Android also refuses to replace an app with one signed by a different key (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`), so the release build could not be installed over it at all -- and the phone reports that as a generic "app not installed". **Uninstall any debug build before testing association, and read `pm get-app-links` rather than trusting a tap**: a debug build on the device makes a correctly published assetlinks.json look broken, which is the same symptom as not having published one. It was verified through the F-Droid repository rather than by sideloading, which is the stronger test: `installerPackageName` is `com.machiav3lli.fdroid`, so those were bytes fetched and installed by a real client, and the signature the OS recorded (`0D:6F:10:46...D3:53:1A:44`) is the one `ANDROID_CERT_FINGERPRINT` publishes. That equality is the whole reason the repository serves our own signed APK instead of being an f-droid.org listing; see `fdroid/config.yml`. ## The bare transport, measured Two runs, on one machine (i5-10210U, 4 cores / 8 threads), with no framing, sealing or sink in the path. Throughput is receiver-side MB/s (1e6 bytes). They answer what the stack can do. Issue #6 asks the other half: whether a different stack would do better between two real devices. ### Loopback baseline, three stacks (2026-09-13) | Stack | MB/s | Notes | | --- | --- | --- | | iroh 1.2 (Rust, `presets::Minimal`, relay off, ip-only) | 136-171 at 256 MiB; 178 at 1 GiB | ~2.7 cores; 16 KiB and 1 MiB writes barely differ | | node-datachannel 0.33.0 (the CLI's stack) | ~21 at 256 MiB and 1 GiB | ~2.5 cores; 16 and 64 KiB messages identical | | Chromium 151 in-page (the WebView2 and Android WebView stack) | ~9 at 16 KiB; 8.5-14 at 64 KiB | | The Chromium figure matches Phase 2's 9.5 MB/s for the *full* qrdrop stack, so that ceiling was Chromium's data channel and not qrdrop's overhead. Loopback has no round trip, no loss and no congestion control to speak of, so it measures how much CPU each stack spends per byte and nothing else. The `@number0/iroh` Node binding was ruled out for any spike: its stream APIs take and return `Array`, so its numbers would measure that conversion rather than QUIC. An app spike has to be a Rust Tauri command. ### Chromium's data channel against a real round trip (2026-09-26) The question was whether the Chromium data channel could be made faster from JS, and the obvious suspect was Trystero's action wire. It cuts every message to `16 * 1024 - 36` bytes, and it holds the channel's send queue under 64 KB (`bufferedAmountLowThreshold = 65535`, awaited before every chunk). Bypassing both would mean a second, pre-negotiated `RTCDataChannel` on the paired connection for chunk frames. `sender.js`'s `drain()` would then come alive, and a capability flag would be needed because the frame size is a wire break. So it was measured first. Chromium 151.0.7922.34 talked to itself over one bare pre-negotiated channel, across message size × queue depth × round-trip time. Figures are the steady per-second rate once running, not the run average: several runs spent their first few seconds near zero before ramping, and averaging that in makes the table report start-up luck. | RTT | 16 KiB messages | 64 KiB messages | queue depth (64 KB / 1 MB / 4 MB) | | --- | --- | --- | --- | | ~0 (loopback) | 8-10 | 15-25 | a small gain from a deeper queue | | 2 ms | 9-10 | ~15 | no difference | | 5-10 ms (LAN-like) | 14-17 | 14-17 | no difference | | 20 ms | 8-13, sawtooth | 8-13, sawtooth | no difference | | 50 ms | 1-2.4 | 1-2.4 | no difference | | 100 ms | 0.6-1.0 | 0.6-1.0 | no difference | 256 KiB messages were never better than 64 KiB, and with a 4 MB queue they were worse. **Message size matters only on the same machine, and queue depth hardly matters anywhere.** From 5 ms upward, every combination lands in the same band. The reason is visible in the per-second curve at 50 and 100 ms. The rate climbs by one ~1,200-byte packet per round trip -- about 0.4 MB/s each second at 50 ms, 0.1 at 100 ms -- and then halves. That is Reno-style additive increase with a loss response, inside Chromium's SCTP (dcSCTP). JS has no control over it, and a deeper send queue only gives the sender more data waiting behind a congestion window that is not open. **Decision: the bulk channel was not built.** It would be a wire break for a gain that appears only with both ends on one machine, which is not a transfer anyone makes. The browser path stays Trystero's data channel. The one lever left is a different stack in the native builds, which is issue #6, and that was always the real question. **How it was measured, so it can be repeated without sudo.** WSL Ubuntu 24.04. `unshare -rn` gives a user and network namespace that owns its own `lo`, and inside it `tc qdisc replace dev lo root netem delay ms limit 100000` needs no privilege. The raised `limit` matters: netem's default 1,000-packet queue drops at these rates, and a drop is exactly what the measurement is trying to observe. Chromium needs a dummy interface **and a default route** in the namespace (`ip link add d0 type dummy`, an address, `ip route add default dev d0`). Without the route it treats itself as offline and gathers no ICE candidates at all. The WSL system Node is 18, which Playwright refuses, so Node 24 came from `mise exec node@24`. Chromium's shared libraries were already present. **netem on `lo` reorders packets, so do not reuse this setup for anything that reacts to reordering.** Found on 2026-10-17 (see "The Rust WebRTC stacks" below): the delay line is one qdisc whose release timer fires on whichever CPU armed it last, and packets released on different CPUs overtake one another on the way to the socket. About 0.4% of SCTP DATA chunks arrived out of order at 2-20 ms, none with no qdisc, none with everything pinned to one CPU, and every reorder can cost an SCTP sender a fast-retransmit halving of its window. The Chromium rows above survived a re-measure on the clean harness below (1.5 MB/s at 50 ms, 1.0 at 100), so their conclusion stands; anything new uses the two-namespace veth harness instead. A Node UDP delay proxy on Windows was tried first, and it cannot do this job: its timers fired 24-71 ms late even on a worker thread with a `setImmediate` spin loop, and SCTP reads that as loss. With a 4 MB queue it drove the channel to zero and held it there. A delay line in user space on Windows measures the delay line. ### WebSocket against the same round trips (2026-09-27) The candidate for a native-only LAN fast path is a direct WebSocket carrying the same sealed frames. It uses WebSocket rather than raw TCP because a page can open one with no IPC in the way. Android's page-to-Rust path is base64 JSON, and nothing carries bytes from Rust to the page, so a Rust socket feeding frames through `invoke` would give back the gain. The measurement rule was that it has to clearly beat the 14-17 MB/s the data channel holds at LAN round trips. Same namespace and netem setup as above. Chromium 151 is the WebSocket client, and a Node `ws` server is the peer, with 16 KiB messages and the sender held under 1 MB `bufferedAmount`. **up** is Chromium sending (the phone or app as sender); **down** is Chromium receiving. Run averages over 512 MiB, every cell complete: | RTT | up | down | data channel, same RTT | | --- | --- | --- | --- | | ~0 | 64 | 44 | 8-25 | | 2 ms | 72 | 64 | 9-15 | | 5 ms | 76 | 58 | 14-17 | | 10 ms | 73 | 53 | 14-17 | | 20 ms | 79 | 67 | 8-13 | **About 4x the data channel across the LAN range, in both directions.** The first part of the rule passes. The comparison isn't perfectly like for like: the data channel ran Chromium to Chromium, and this ran Chromium to Node. The app-to-app path will be Chromium to a Rust relay to Chromium, which has not been measured yet. Two anomalies turned up, and both were chased: - **A few hundred KB "missing" at 0-5 ms (harness bug).** The first run showed Chromium receiving all but ~117 messages and then idling until the timeout. `ss` showed its socket had received every byte, and a message sent afterwards still arrived. So the missing ones were the *first* messages: Node began sending before `page.evaluate` had installed `onmessage`, and a WebSocket message dispatched with no handler is discarded. The same rule will bind the real link: the frame handler must be live before the peer can send. - **down at 100 ms is flat at 0.6 MB/s, and it is the receive window.** Node's socket was `rwnd_limited` 99.3% of the time with `snd_wnd` ~74 KB, and Chromium's receive buffer sat at 128 KB without autotuning upward. 74 KB per 100 ms is ~0.7 MB/s. This is outside the LAN range this path is for, so it was not chased further. It is also the first thing to look at if the path is ever widened beyond one network. (50 ms ran at 40 MB/s.) ### The Android WebView, on the phone (2026-09-27) The Realme RMX3868 (Android 16, WebView 153.0.8010.36) was on the same Wi-Fi as the Windows desktop. The phone ran the arm64 debug APK and was driven over CDP from `http://tauri.localhost`. The desktop ran a Node `ws` server on `0.0.0.0`. Messages were 16 KiB, with 1 MB of `bufferedAmount` on each sender, and each run moved 256 MiB. - **The shipped CSP blocks it, as it should.** A `securitypolicyviolation` fired with `connect-src blocked ws://192.168.1.3:47811/`. So the app channel's policy needs `ws:`, and only that channel's: the website must never be able to open one. - **Local Network Access does not get in the way.** With the CSP bypassed (`Page.setBypassCSP`, standing in for `ws:` in `connect-src`), the socket opened in 58 ms with no prompt and no error. That is what the spec predicts: `*.localhost` is already the loopback address space, and a request to a private address is not a step inward from there. - **Throughput, against a bare data channel between the same two devices.** Both were measured back to back. The data channel was host to host over UDP, not relayed, and Trystero-shaped: 16 KiB less 36 bytes per message, and `bufferedAmountLowThreshold` 65535. | | WebSocket | Data channel | | --- | --- | --- | | Phone sends | 29.4-32.2 MB/s | 4.4-4.9 MB/s | | Phone receives | 32.4-40.8 MB/s | 5.9-7.3 MB/s | **About 6x on a real phone, in both directions**, which is more than the 4x seen in the namespace. The data channel is also lower here than there, and the phone's single network thread is the likely reason. The WebSocket curves are flat from the second second on, so no congestion-control ramp shows up anywhere in a 256 MiB run. The phone-send figure also explains item 1 above. The app's 4.03 MB/s was already close to the channel's own ceiling between these two devices, so the work on the read side had taken the sender about as far as the data channel allows. Both of Phase 0's conditions for the Android side hold: the phone is not blocked, and the path clearly beats the data channel. What remains before any code is the desktop app's Rust relay, which sits between its page and the LAN socket. ### The Rust relay, on both ends (2026-09-27) A webview cannot accept a connection, so the app's page reaches the LAN through a relay in its own Rust process. The page connects to `127.0.0.1`, and the relay forwards to the peer. The spike was a standalone tokio binary with two modes: - **`ws`** ends the WebSocket on both sides and forwards each message whole. `tokio-tungstenite` 0.28 does the framing, and each message is sent (and so flushed) as it arrives. This is the listening role, where both the page and the LAN peer connect in as clients. - **`splice`** is `copy_bidirectional` between two TCP sockets, so the page's WebSocket runs end to end to the peer. This fits only the connecting role. Measurements, 16 KiB messages, 1 MB of `bufferedAmount`: - **Desktop, Windows loopback.** Chromium 151 (WebView2's engine) talked to a Node `ws` peer, 512 MiB a run, two rounds. Direct ran 44-55 MB/s, the `ws` relay 46-54 and the splice 48-53. The spread is round-to-round noise in the endpoints, and nothing in it tracks the relay. - **The phone, which matters more.** A phone pairing with a Windows app or a Windows CLI is the case where the *phone* listens, because the Windows side would get a firewall prompt. The same relay was cross-compiled for `aarch64-linux-android` and run from `adb shell` on the RMX3868. The WebView's page connected to it on `127.0.0.1`, and it forwarded over the Wi-Fi to the desktop. Runs were interleaved with direct ones, 256 MiB each: | Phone ↔ desktop | Direct | Through the on-phone relay | | --- | --- | --- | | Phone sends | 31.9-40.5 MB/s | 38.8-41.4 MB/s | | Phone receives | 41.9-51.1 MB/s | 41.2-49.8 MB/s | **The relay costs nothing measurable on either end.** At these rates it is under the noise between one Wi-Fi run and the next. These direct figures are higher than the section above, because the Wi-Fi was better in this session. Treat roughly 30-50 MB/s as the range for this phone. Two stalls turned up, and both were chased: - **Direct connects timed out, and it was the screen.** The display had timed out mid-session, and ColorOS cuts a backgrounded app's network. The relay, started from `adb shell`, kept running under the shell's own user, which is why it could reach the desktop while the app could not. This is the same vendor behaviour recorded above for the save dialog, and it applies to WebRTC just as much. It is not specific to the socket. - **A second receive run stalled at zero after a few seconds, once direct and once relayed: a harness bug.** The previous receive run's 15-second deadline was never cleared. After a run that finished early, it fired during the next one and removed that run's `onmessage`. With the timer cleared, every run completed. None of the figures above come from a stalled run. **Phase 0 passes.** On the LAN, the WebSocket path is 4x the data channel in the namespace and about 6x on the phone. Android's WebView reaches it once `ws:` is in the app channel's `connect-src`. The relay the app needs to listen costs nothing measurable on either platform. The spike ran the relay outside the app's process, so the one thing still unproven is that it behaves the same inside the Tauri process on Android. That check comes with the first device build of `lan.rs`. ### The fast path, built, on the phone (2026-09-27) This is the implementation, not the spike. `lan.rs` runs inside the app process, and `createLanLink` switches the sender at Accept. The app was a debug build on the RMX3868, driven over CDP, paired over real relays with the Windows CLI. The Windows side never listens, so the phone listened every time, through the in-process relay. 128 MiB a run. Every digest matched. `netstat` showed a TCP connection to the phone on each fast-path run and none on the `--no-lan` runs. | 128 MiB, phone ↔ Windows CLI | WebRTC (`--no-lan`) | Fast path | | --- | --- | --- | | Phone sends (from Accept) | 5.5 MB/s | 13.0 MB/s | | Phone receives (from the Save tap) | 4.3 MB/s | 5.8 MB/s | **The in-process relay works on Android.** The relay was the one thing Phase 0 left unproven, and it is proven now: same listener, same token handshake, reached from the page on `127.0.0.1` with the app in the foreground. **Neither direction reaches the socket's 30-50 MB/s, and the bottleneck is different in each.** - **Receiving is capped by the sink.** On Android a block crosses to Rust as base64 in JSON ("Android has no raw body", CLAUDE.md), and a whole transfer on that path measured about 3.4 MB/s when it was fixed. The fast path lifts the transport ceiling, and the save path becomes the ceiling. Raising it needs a byte path from the page to Rust on Android, which the fast path does not change. - **Sending is not yet attributed.** The source here was an in-memory `File`, so this is not the SAF read cost, and AES-GCM measured about 140 MB/s on this phone. 13.0 MB/s is about 1.3 ms per 16 KiB frame across `sender.js`'s loop: seal, send, wait for drain. The likely suspect is a per-call WebCrypto round trip in the WebView, which the bare-socket spike never paid. It has not been measured. Still not run: app ↔ app between two phones, and CLI ↔ CLI across two machines. That last one ran only on one machine, with `QRDROP_LAN_LISTEN` forcing the roles. Over real relays with 256 MiB, the receiver listening ran 32.8 MB/s, the sender listening 41.7 and `--no-lan` 17.0. ### The desktop app and the phone, app to app (2026-09-27) The Windows desktop app (debug, `750d1cd`) against the Android app (debug, same commit), 128 MiB each way over real relays, both pages driven over CDP. Windows says `prompt` and the phone `quiet`, so the phone listened through `lan.rs` in both directions and WebView2 dialled it from the page's own WebSocket. That dial is what had never carried a file. - **Desktop app sends, phone receives:** 6.3 MB/s from the Save tap to both sides reaching done, a direct TCP connection from the desktop to the phone's relay port throughout, digests equal. The phone's save path is the ceiling here, as it was against the CLI. - **Phone sends, desktop app receives:** the file arrived whole (134,217,728 bytes, digests equal, both sides `lan`), but there is no rate. The harness lost its CDP handle to the desktop page when Windows' save dialog opened, and the dialog was answered by hand. Both badges moved from "Local network" to "Local network, direct" during the transfer, the sender's at Accept and the receiver's at its first frame over the socket, which is the indicator's first run outside a unit test. The phone offered exactly one address, 192.168.1.7, its Wi-Fi. That is the `getifaddrs` listing in `lan.rs` running on the device, since a Windows host never compiles it: loopback and the down interfaces were filtered, and nothing that is not private was offered. A phone with a VPN up has not been tried. ### The phone's send, attributed (2026-09-27) The open question from "The fast path, built": the phone sent at about 13 MB/s over a socket that had carried 30-50 bare. Same RMX3868 debug app, same Windows CLI receiving, 64 MiB a run over real relays. The WebCrypto guess above was wrong, and so were three others. **Ruled out, each by measurement on the phone:** - **WebCrypto.** In the app's WebView, one 16 KiB frame costs 63 µs to encrypt and 22 µs to hash, and the sender's three calls in a row come to 94 µs, a ceiling of about 174 MB/s. Adding all three to a bare send loop took it from 43.3 to 40.5 MB/s. - **The in-process relay.** A bare loop from the page through `lan.rs` to a Node client on the desktop ran 39.6-43.3 MB/s in the *debug* build, the same as the page going straight to the desktop (32-41). Unoptimised Rust unmasking every client frame was a plausible suspect, and it is not one. It made no difference whether the desktop end read with `ws` or with Node's built-in WebSocket, which is what the CLI uses. - **The receiver.** A CPU profile of the CLI through a whole run has its main thread idle 38% of the time, and it asked the phone to pause once, for 245 ms, while it opened the file. - **The drain poll.** `wrapWebSocket`'s `drained()` polls with `setTimeout(1)`, which this WebView clamps to 4.6 ms. Swapping it for a `MessageChannel` hop gave 14.2 and 16.4 against 13.9 and 14.4, which is inside the noise, and spinning that way made a run slower. **What it is: main-thread time per frame.** A Chromium trace of a send has the page's main thread busy about 0.7 ms per frame, and the same bare loop with 0.5 ms of busy work per message drops from 43 to 24 MB/s. The socket lives on other threads of the same phone, so every millisecond the page spends between frames is a millisecond it does not refill it. The largest single piece was the UI: `_trackProgress` called `_setState` after every frame, so the whole view rendered 4,116 times in a 64 MiB run, 0.53 ms each. Rendering at most every 100 ms, four alternating rounds each: | 64 MiB, phone → Windows CLI | Run 1 | Run 2 | Run 3 | Run 4 | Mean | | --- | --- | --- | --- | --- | --- | | Render every frame | 11.9 | 13.3 | 11.3 | 12.4 | 12.2 MB/s | | At most every 100 ms (patched in the page) | 14.8 | 15.7 | 15.5 | 16.1 | 15.5 MB/s | The change itself, built into a debug APK (`_trackProgress` with `PROGRESS_INTERVAL_MS`), then eight 64 MiB runs in a row, every one over the direct socket with digests equal: 15.1, 8.2, 15.9, 22.1, 23.1, 23.2, 12.3 and 12.8 MB/s. The median is 15.5, which agrees with the patched rounds. The spread does not: runs seem to fall at 12-16 and at 22-23, with one at 8.2, where eight runs of the old build the same afternoon all fell between 11.3 and 13.3. It is unexplained. Wi-Fi weather is the obvious candidate, and it has not been shown. Treat the gain as real and its size as roughly a quarter until a longer series says otherwise. One trap from this run, for the next device session: `tauri android build --debug --target aarch64` writes `apk/universal/debug/`, not `apk/arm64/debug/`. The file at the second path was an old build without `lan.rs`, and installing it made every run fall back to WebRTC at 5 MB/s while the page looked normal. `invoke('lan_mode')` answering "Command not found" is how that shows. What is left is spread thin, with no hot function to go after: in a JS profile of a run with renders limited, `WebSocket.send` is about 0.12 ms a frame, the calling side of the three WebCrypto calls about 0.2, the engine's own task and promise machinery about 0.3, and GC under 0.1. Each of those is paid per frame, so the next step on this path is fewer, larger frames rather than faster code. `CHUNK_SIZE` is set by the data channel's message size, the direct socket has no such limit, and a frame size that varies by path changes the protocol, so that is a decision and not a patch. ### The Rust WebRTC stacks, against each other and the JS ones (2026-10-17) Phase 1 of `docs/notes/rust-session.md`: a throwaway binary per candidate crate, str0m 0.24.1 and webrtc-rs 0.21.1, with the SDP carried by hand and every message wrapped exactly as Trystero 0.25.4 wraps a binary `frame` action (room frame, action chunk, 16,348 payload bytes, 16,451 on the channel). Both crates interoperate with node-datachannel and with Chromium 153 as offerer and as answerer, in both directions, with every byte checked. Chromium kept its default mDNS host candidates, so the peer-reflexive path a real browser forces on a native peer works too. Throughput is receiver-side MB/s (1e6 bytes), 256 MiB per run (64 MiB at 50 and 100 ms). **Loopback, Windows, i5-10210U, no pinning.** | Pairing | Rust sends | Rust receives | | --- | --- | --- | | str0m and node-datachannel 0.33 | 28-36 | 39-41 | | str0m and Chromium 153 | 33-36 | 26-29 | | str0m and str0m | 51-53 | | | webrtc-rs and node-datachannel | 39 | 35 | | webrtc-rs and Chromium 153 | 27-33 | 29-30 | | webrtc-rs and webrtc-rs | 26-27 | | | Chromium 153 and itself, in one page | 15-17 | | str0m to str0m was 10-13 until both sockets were given 4 MB buffers: the Windows default drops the bursts only a native peer sends this fast, and SCTP then lives in loss recovery. Against a JS peer the buffers change nothing, because the JS end is the ceiling. **Round trips, WSL, the clean harness.** Two network namespaces joined by a veth pair, `netem delay ms limit 100000` on each end's egress, and each peer pinned to its own core (`taskset`), so each delay line is only ever armed by one process and order holds per direction. Measured with sctp-proto's trace: 48,001 DATA chunks at 20 ms, none out of order, no fast retransmits. One core per peer is also what holds str0m to str0m near 19 below: its sender is CPU-bound in a spike that spins and makes one syscall per datagram, and webrtc-rs's sender feeding str0m shows the path carries 45-54. | RTT | str0m to str0m | webrtc-rs to webrtc-rs | webrtc-rs to str0m | str0m to webrtc-rs | Chromium to Chromium | str0m to Chromium | Chromium to str0m | | --- | --- | --- | --- | --- | --- | --- | --- | | ~0 | 16-18 | 13.5 | 54 | 21-25 | 12 | 16 | 14.5 | | 2 ms | 14.5 | 11-12 | 44-47 | 15-23 | | | | | 10 ms | 19 | 4.4-4.8 | 45 | 5.3-6.1 | 5.8 | 5.0 | 16.7 | | 20 ms | 19 | 2.7-3.1 | 46.5 | 7-23 | 3.6-3.8 | 3.1-3.8 | 9-12.5 | | 50 ms | 16 | 1.5-1.6 | 18.7 | 2.3-8.1 | 1.5 | 1.5 | 5.8 | | 100 ms | 9 | 0.8-0.9 | 8.9 | 0.9-1.0 | 1.0 | 1.0 | 6.2 | **The receiver decides the rate, and str0m is the one that holds up.** Every column with str0m receiving degrades slowly with RTT; every column with webrtc-rs or Chromium receiving falls as roughly 80-100 KB per round trip, which is a receive window and not congestion control. str0m's own long-RTT ceiling is its 1 MiB SCTP receive buffer (1 MiB per 100 ms is 10.5 MB/s, measured 9), and sctp-proto exposes that as a setting. For qrdrop that means a native receiver gains four to six times over the internet even when the other end is a browser, and a native sender to a browser gains nothing, because the browser's receive side is where Chromium's 1-2 MB/s lives. Four things a real session must carry, each found by a number that did not fit: - **str0m caps a whole association's send buffer at 128 KiB by default**, in-flight bytes included (`RtcConfig::set_sctp_max_buffered_amount`). At 20 ms that pinned str0m to str0m at 3.96 MB/s, the same to three digits run after run; sctp-proto's trace showed cwnd refusing to grow with `pending=0`, a sender starved by the cap rather than limited by the path. Raised, the same run is 22.7. - **Socket buffers, on both platforms.** 4 MB requested on each socket; with Linux's defaults the 20 ms run fell to 5.7 again. - **webrtc-rs fails `PeerConnectionBuilder::build()` with `ENODEV` when the host has no default route**, before any ICE runs. A LAN with no gateway is that host. - **str0m's `rust-crypto` backend is not pure Rust as shipped.** `str0m-rust-crypto` 0.6.0 enables dimpl's `rcgen` feature, which in dimpl also enables `aws-lc-rs`, a cc and cmake build, only to generate the DTLS certificate. The spike vendors that crate with certificate generation removed and passes its own through `RtcConfig::set_dtls_cert`, made with `p256` and `x509-cert`. That certificate must be a self-issued **leaf**: `Profile::Root` sets key usage to certificate and CRL signing only, and OpenSSL in node-datachannel refuses it for ECDHE-ECDSA as `bad ecc cert`. **Reproducibility, aarch64 Android.** Each spike built twice for `aarch64-linux-android` with NDK r29 (`29.0.14206865`), from two checkout directories of different lengths and two fresh `CARGO_HOME`s, with `scripts/android-env.mjs`'s flags (Tauri's three link arguments plus both remaps). webrtc-rs, ring's C build included, came out byte-identical, with no build path anywhere in the binary. str0m did too, but only once the vendored crate moved inside the spike's own workspace: with it at `../str0m-rust-crypto` the two binaries differed by 128 bytes with no path in either, because Cargo hashes a path dependency outside the workspace root by its absolute path into every symbol name. `app/src-tauri` is its own workspace and is planned to depend on `crates/*` by path, which is exactly that shape. ### The QUIC path, against SCTP (2026-10-18) Phase 9 of `docs/notes/rust-session.md`: two Rust sessions, each running `crates/qrdrop-session/examples/bench.rs`, with the file from memory into nothing, timed by the receiver from its Accept to the verified digest. The WebRTC column is the same session with the QUIC path left off, so both columns carry the same pairing, AEAD and frames, and differ only in what is under them: str0m's SCTP, or one quinn 0.11 stream (Cubic, 16 MiB windows). Receiver-side MB/s (1e6 bytes), two or three runs each, digests equal. WSL, i5-10210U, one pinned core per side, for the reason "The Rust WebRTC stacks" gives about netem reordering across cores. `examples/netem.sh` is the harness. **Clean delay, 256 MiB (64 MiB at 50 and 100 ms).** The WebRTC figures are after `84cecc6` gave the room's socket 4 MB buffers; before it they were 8.4, 5.4-7.5, 5.9-15.5 and 1.2. | RTT | WebRTC | QUIC | | --- | --- | --- | | ~0 | 25.2-26.5 | 101-108 | | 20 ms | 16.9-18.0 | 93 | | 50 ms | 12.2-13.2 | 51 | | 100 ms | 8.5 | 32 | **Loss, 16 MiB.** At 0.5% loss each way SCTP collapses and QUIC degrades; with a 100 Mbit/s bottleneck and 0.1% loss, closer to a home uplink, QUIC is four to ten times faster at every delay. | RTT | 0.5% loss, WebRTC | 0.5% loss, QUIC | 100mbit + 0.1%, WebRTC | 100mbit + 0.1%, QUIC | | --- | --- | --- | --- | --- | | 20 ms | 0.85-0.89 | 3.8-9.3 | 2.4-2.5 | 10.7 | | 50 ms | 0.32-0.37 | 6.4-11.3 | 0.9 | 9.1-9.2 | | 100 ms | 0.17-0.18 | 0.87-1.22 | 0.62-0.82 | 6.2-7.2 | **Behind two NATs, 64 MiB.** `examples/nat.sh`: each peer behind its own iptables MASQUERADE, the relay and a STUN responder in the namespace between them, half the RTT on each internet link. The punch succeeds from the reflexive candidates alone (neither side can reach the other's private address, which the script checks). | RTT | WebRTC | QUIC | | --- | --- | --- | | ~0 | 26.3-33.4 | 104.9-105.2 | | 50 ms | 14.0 | 49.4-50.1 | **Fallback.** With every QUIC long-header packet dropped on the internet (`BLOCKQUIC=1`), the handshake cannot finish; the sender waits out `quic::LINK_WAIT` at Accept and goes on over WebRTC, 16 MiB at 50 ms in 4.5 s against 1.2 s with nothing to wait for. That 3 s is the designed cost of an offer nobody answers, paid once. A receiver without the path costs the sender nothing, because its silence is read at Accept rather than timed (`a_receiver_without_quic_costs_the_sender_no_wait`). **The first NAT run failed, and the harness was why.** WebRTC paired and then never connected: each NAT forwarded nine checks out to the other's mapped address and none in. Both sides punch at once, so a check reaches the far NAT before that NAT has a mapping, is delivered to the NAT namespace itself, and has its conntrack entry confirmed -- on exactly the tuple the peer behind it is about to send on, so MASQUERADE gives that peer a different source port and every later check misses. A real router drops unsolicited WAN input, which leaves the entry unconfirmed; with that one rule both paths connect on the first try. Recorded because the symptom reads exactly like a traversal bug in `tryst`. **Not measured: the phone.** The app is built without `quic`, and the gate for turning it on there is the RMX3868, as for phase 7's native session. A raw quinn spike on the same harness, one stream and no session, measured Cubic at 531, 92, 49 and 115 MB/s at 0, 20, 50 and 100 ms, and BBR at 416, 252, 144 and 79. Neither wins throughout, and Cubic's 50 ms figure below its own 100 ms one says the spike is noisy at that size. Cubic stays, as quinn's default, until a real path says otherwise; at 20 ms the session's 93 is the spike's Cubic figure, so there the controller is the bound.