Apple Receive Throughput
v10 transfers ran at 43 mbps on a 900 mbps link (RESOLVED, 2026-07-25)
Cause: the Rust sender was built in debug mode. snow's ChaCha20-Poly1305 runs at
~13.8 MB/s at opt-level = 0 versus ~291 MB/s in release, and EncryptedStream::poll_write
encrypts inside the write call — so the sender was CPU-bound at ~5 MB/s while appearing, to
its own instrumentation, to be "blocked on the socket". A release build moved the same
transfer from 43 mbps to 907.8 mbps, matching iperf3's 907 mbps ceiling for this link.
Nothing on the Apple side was at fault. The receiver was idle 95% of every transfer.
Fix on the Rust side:
- Build release for any throughput testing, and
- [profile.dev.package."*"] opt-level = 3 in the workspace Cargo.toml, so dev builds
optimize dependencies while keeping app code debuggable.
- Fix the sender's Diag: line before deleting it: it reports time in write_all as
"socket", but that call encrypts too. That single mislabel sent this entire investigation
to the wrong machine.
What this cost, and the lesson
Three confident theories about the Swift receiver were each disproven by the next
measurement:
| theory | disproved by |
|---|---|
| Too many async round trips per 64KB record | Cutting receives per MB 8x moved throughput 0.7% |
| The TCP window collapses between on-demand reads | NWConnection hit 892 mbps with 45KB reads, 2400/sec |
| 64KB Noise records are too small | Prefetching whole 5MB chunks (153 receives -> 2) moved it 0% |
The receiver was never measured directly until late; every number came from the sender,
whose timer conflated CPU with I/O. Measure the machine you suspect, and distrust any timer
wrapping a call that does both compute and I/O.
Symptom (as originally reported)
Windows (Rust sender) → macOS (Swift receiver), shared network mode, 4.58 GB file:
~43 mbps (5.35 MB/s), where SMB moved the same file, from the same disk, over the same
network, to the same MacBook at ~600 mbps. Later measured directly: a single plain TCP stream
over that link does 907 mbps.
What the measurements say
A temporary diagnostic in the Rust sender (core/src/sending.rs) splits the send loop's
wall time into disk-wait and socket-wait, reported every 5s:
Diag: 43.2mbps over last 5s — 27 chunks: disk 0.2ms/chunk, socket 185.0ms/chunk (1MB chunks)
Diag: 42.8mbps over last 6s — 6 chunks: disk 1.0ms/chunk, socket 933.5ms/chunk (5MB chunks)- The sender is blocked on the socket ~100% of the time. It is not the network and not
the sender: TCP flow control is throttling it because the Mac drains at 5.35 MB/s.
- The disk is irrelevant (0.2-1.0 ms/chunk; the file is in the page cache).
- The limit is per byte, not per chunk. 5x the chunk size gave 5x the time per chunk
and identical throughput. So chunk size is not the lever.
Ruled out, with the evidence
| Hypothesis | Verdict |
|---|---|
| ChaCha20-Poly1305 slower than v9's AES-GCM | This was the right neighbourhood and was dismissed too fast. The benchmark below was run with cargo test --release, so it never exercised the debug build the app actually ran. core/tests/throughput.rs measures 968 MB/s encrypt, 1633 MB/s decrypt, and 769 MB/s through the whole Rust EncryptedStream over loopback TCP — 150x the observed rate. |
| Nagle's algorithm on the sender | No. The send loop writes a small length prefix then immediately queues the body, so Nagle coalesces rather than stalling; it needs a write-write-read pattern. set_nodelay(true) was added anyway (correct for this protocol) and changed nothing. |
| Chunk size | No. See above — 1 MB and 5 MB give the same throughput. |
| Sender's spinning disk | No. 0.2 ms per 1 MB read. |
| macOS app built in Debug | No. A Release build measured the same, which also rules out Swift-level compute cost and points at per-call latency. Note the irony: the receiver's build mode was checked and cleared, the sender's was never asked about. That was the answer. |
Mechanism — WRONG, kept as a record of the wrong turn
*Everything in this section was disproven. The reasoning is plausible and the code reading is
accurate; the conclusion is not. Reducing these round trips 8x changed throughput by 0.7%.*
The (incorrect) claim: a v10 regression introduced with Noise
v9 read a whole chunk in one call, so Network.framework could deliver up to 5 MB per
callback:
// v9, Apple/shared/Receive.swift (commit 6996b38~1)
receiveNBytes(n: chunkSize) // -> receive(minimumIncompleteLength: 0, maximumLength: 5_000_000)v10 routes the same read through NoiseConnection, which pulls one 64 KB Noise record at
a time, and every record costs two async receives — Apple/shared/Noise.swift:333:
private func readNoiseFrame(_ tcp: any TCPConnectionProtocol) async throws -> Data {
let lenBytes = try await tcp.receiveNBytes(n: 2) // asks Network.framework for 2 bytes
let len = (Int(b[0]) << 8) | Int(b[1])
return try await tcp.receiveNBytes(n: len) // capped at 65535
}Two things got worse at once:
1. Max delivery per callback fell from 5,000,000 bytes to 65,535.
2. A mandatory 2-byte round trip was added per record.
A 4.58 GB file is ~70,000 records, so at minimum ~140,000 async receives where v9 needed a
few thousand. It is worse than that in practice because receiveUpToNBytes (as it then
was, in Apple/shared/Network.swift) passed minimumIncompleteLength: 0, which let each call
return as little as a single TCP segment — 3M+ receives for this file.
This is per byte (records are 64 KB regardless of chunk size), which is exactly what the
measurements show.
The Rust receiver does structurally similar small reads (8 KiB per poll_read inEncryptedStream) but they are ordinary syscalls at ~1-2 µs, not dispatch hops with
checked continuations — hence Rust→Rust is unaffected.
What was changed on the Apple side (kept, but not the fix)
Local to Swift, no wire change, no KAT impact, no coordination with the Rust or Kotlin
ports. NoiseConnection already buffered plaintext above the framing; what was missing
was the equivalent buffering of ciphertext underneath it.
1. BufferedCiphertextReader (Apple/shared/Noise.swift) sits between NWConnection and the
Noise framing. It pulls NOISE_SOCKET_BLOCK (256 KB) at a time and serves the 2-byte
lengths and 64 KB record bodies out of memory, so ~4 records cost one socket read instead
of 8. noiseHandshake wraps the connection once and hands that same instance to the
NoiseConnection it returns — a handshake read may pull transport bytes in with it, and
they would be stranded if the transport then read from the bare connection.
2. minimumIncompleteLength is now the caller's real minimum, not 0
(TCPConnectionProtocol.receiveSome in Apple/shared/Network.swift, replacing
receiveUpToNBytes). Every read here is of a protocol field whose length is already
known, so the transport is asked for the whole remainder and delivers it in one callback
instead of one per TCP segment. The minimum is never more than what the peer already
owes — this protocol has points where the peer is waiting on our reply, so over-asking
would deadlock.
- receiveSome became a protocol requirement rather than an extension-only method.
The wrappers (RecordingTCPConnection, BufferedCiphertextReader, NoiseConnection)
have to serve reads from their own state; as an extension method it dispatched
statically and a caller holding a wrapper would read straight off the socket, around
the buffer or the preamble transcript.
3. No O(remaining) drains. The ciphertext buffer uses an offset that compacts once per
refill. Above the framing, NoiseConnection.receiveSome accumulates decrypted records
directly into the buffer it returns and keeps at most one record's tail, so a 5 MB chunk
read no longer copies 5 MB out of a staging buffer and then memmoves the remainder.
Also on the send side: NoiseConnection.write coalesces records into 256 KB batches, so a
5 MB chunk costs 20 awaited sends instead of 77. Same framing on the wire — only the number
of NWConnection.send calls changes.
This did not fix the bug (43.2 → 43.5 mbps on hardware). It is kept because it is
correct on its own terms and measures 1.24x on loopback, and because it makes the receive
count a variable we can now rule out. NoiseTransportBufferingTests covers it: transparency
across delivery sizes, unaligned reads spanning record boundaries, unchanged send framing,
and ~4 socket receives per megabyte instead of 32+.
The receiver instrumentation (since removed)
While this was open, Apple/shared/Receive.swift emitted the counterpart to the Rust sender's
line every 5s. It has been removed now that the bug is closed; the patch is recoverable from
the session scratchpad (instrumentation.patch) if it is ever wanted again:
Diag: 43.5mbps over last 5s — 6 chunks: socket 900.0ms, decrypt 8.0ms, disk 1.4ms, other 2.0ms per chunk; 20 socket reads averaging 256KBsocket is time blocked inside NWConnection.receive; decrypt is time insideChaChaPoly.open; disk is seekToEnd + write. The averaging figure is how much each
receive actually returned.
What the receiver actually reports (2026-07-25, 4.58 GB Windows → macOS)
Diag: 43.6mbps over last 6s — 6 chunks: socket 874.8ms, decrypt 33.9ms, disk 2.3ms, other 7.0ms per chunk; 918 socket reads averaging 32KB
Diag: 43.2mbps over last 6s — 6 chunks: socket 884.9ms, decrypt 30.8ms, disk 3.5ms, other 7.6ms per chunk; 913 socket reads averaging 32KBThe Mac is idle 95% of the time. Decrypt 34 ms, disk 2-3 ms, our own copying 7 ms — 44 ms
of work per 5 MB chunk against 875 ms of waiting. Every candidate inside this app is now
measured and none of them is the bottleneck.
The shape of the waiting: 153 reads per chunk, 5.7 ms blocked each, 32 KB returned each,
where 256 KB was asked for. Blocked-then-32KB means the kernel buffer was empty when we
asked — the receiver is starved, not busy. 32 KB per 5.7 ms is 5.6 MB/s, which is the entire
deficit, and it is the signature of a window-over-RTT limit.
Both ends are now known to be blocked: the sender in write_all, the receiver in receive.
That puts the bottleneck between them — the path, or TCP flow control over it — not in
either program's compute.
The link is not the suspect: SMB moves 600 mbps between these two machines over the same
path, and the slowness is not specific to shared network mode (it was chosen for testing
precisely because it is the faster path and gives SMB as a control).
What v9 actually did differently
From the v9 source (Apple/shared/Receive.swift at commit d5c018d):
chunkBytes = try await tcp.receiveNBytes(n: chunkSize) // one request for the whole 5MB
... // nothing but append() in the loop
return try AES.GCM.open(sealedBox, using: key) // one decrypt of all 5MBTwo things changed in v10, and the measurements say only the first one matters:
1. The largest outstanding receive fell from 5,000,000 bytes to 65,535. v9 asked for a
whole chunk (maximumLength = the full remainder) and did nothing but append between
calls. v10 routes the read through the Noise framing, which asks for a 2-byte length and
then a ≤64 KB body. Since NWConnection drains the kernel socket only while a receive is
outstanding, v10 leaves it unattended between every record. This is the regression.
2. Decrypts per chunk went from 1 to 77 (5 MB AES-GCM → 64 KB ChaChaPoly records). Real,
but it costs 34 ms of a 920 ms chunk. Making decryption free would take 43 mbps to
~46 mbps. Neither the record granularity nor the AES-GCM → ChaCha20 change is the bug.
The fix
BufferedCiphertextReader.prefetch(atLeast:), called by NoiseConnection.receiveSome
before it reads a chunk's worth of records. The receiver knows the chunk length before the
chunk arrives, so it can safely block on one large receive for the whole thing — v9's shape,
restored under v10's framing, with no wire change. The bound passed down is a deliberate
underestimate of the ciphertext still coming (n + 18 × floor(n / 65519), since each record
costs a 2-byte length and a 16-byte tag); asking for more than the peer owes would block
forever.
Result: it worked, and the throughput did not move
Diag: 43.3mbps over last 6s — 6 chunks: socket 907.1ms, decrypt 13.8ms, disk 1.1ms, other 1.4ms per chunk; 15 socket reads averaging 1958KB
Diag: 43.7mbps over last 5s — 6 chunks: socket 895.5ms, decrypt 14.6ms, disk 1.8ms, other 3.6ms per chunk; 12 socket reads averaging 2442KBReads per chunk fell from 153 averaging 32 KB to 2.5 averaging 2 MB, exactly as
intended. Throughput: 43.3 mbps, unchanged. The Mac now asks for a whole chunk in
essentially one receive and still waits 907 ms for it.
This exonerates the receiver. Bytes arrive at 5.5 MB/s regardless of how they are asked
for, so no change to the receive path can matter. Keep the prefetch anyway — it more than
halved the per-chunk CPU overhead (decrypt 34→14 ms, other 7→1.4 ms) and restores v9's shape
— but it is not the fix for this bug either.
It also retires the read-ahead pump. Processing is now 17 ms of a 920 ms chunk, so
keeping a receive posted during it is worth ≤2%. Not worth ~100 lines of concurrency; the
parked copy should be deleted rather than kept.
Where the bug is not
Everything on the receiving Mac is now measured and cleared:
| | measured | needed to explain 43 mbps |
|---|---|---|
| crypto + framing + NWConnection (loopback) | 690 MB/s | 5.4 MB/s |
| disk write pattern | 3570 MB/s | 5.4 MB/s |
| decrypt, in situ | 14 ms per 5 MB chunk | 920 ms |
| disk, in situ | 1.1 ms per chunk | 920 ms |
| read pattern | 2 MB per receive | — |
| Wi-Fi link (802.11ax, 6 GHz, 160 MHz, −60 dBm) | 1441 mbps PHY | 43 mbps |
Both ends are blocked at once — the sender in write_all, the receiver in receive — with a
healthy radio on this end. That is a path or sender-side limit.
The network is fine, and so is Network.framework
| receiver | result |
|---|---|
| iperf3 (BSD sockets) | 907 mbps |
| NWConnection, with this app's TCP options, 45 KB average reads | 892 mbps |
| Flying Carpet | 43 mbps |
Same Windows sender, same link, same direction, minutes apart. Note the middle row's read
size: 45 KB per receive, 2400 receives a second, and it still saturates the link. Read size
and read pattern were never the problem — 32 KB reads were a symptom of nothing arriving.
(The nwrecv probe used for this is in the session scratchpad, not the repo.)
Where it actually points: the sender encrypts inside write_all
EncryptedStream::poll_write (core/src/noise.rs) encrypts on the
write call:
let take = buf.len().min(MAX_PLAINTEXT);
let n = this.noise.write_message(&buf[..take], &mut msg)?; // ChaCha20-Poly1305, hereSo the sender's Diag: line, which reports time in write_all as "socket", is really
reporting encrypt + socket. "Blocked in write_all ~100% of the time" never meant blocked on
the network. That mislabel is what pointed this entire investigation at the Apple side.
core/Cargo.toml has snow = "0.10" with default features — the pure-Rustchacha20poly1305 backend — and no [profile.*] overrides anywhere in the workspace, so a
debug build runs it at opt-level = 0. Measured standalone on an M-series Mac
(scratchpad/snowbench):
| cipher | debug | release |
|---|---|---|
| snow ChaCha20-Poly1305 (v10) | 13.8 MB/s | 291 MB/s |
| AES-256-GCM (v9's cipher) | 6.5 MB/s | 213 MB/s |
The transfer moves 5.4 MB/s. That is the debug column's order of magnitude and ~50x below
the release column. Note also that hardware AES does not survive a debug build — v9's
cipher measures slower than v10's at opt-level = 0 — so the v9→v10 regression is not
explained by the cipher change. If v9 was measured against a shipped release binary and v10
against a dev build, the build profile explains it by itself.
The clincher is inside a single v10 transfer: the Mac decrypts at 357 MB/s (14 ms per
5 MB chunk, via CryptoKit, always optimized) while the sender encrypts at 5.4 MB/s. Same
algorithm, same bytes, 66x apart.
What to check, and the fix
1. How is the Windows app built? npm run tauri dev / cargo run without --release
builds the core in debug. Re-run the transfer from a release build; if it jumps, done.
2. Durable fix either way — in the workspace Cargo.toml, optimize
dependencies even in dev builds, keeping the app's own code debuggable:
[profile.dev.package."*"]
opt-level = 3 Crypto crates are exactly the case this profile setting exists for.
3. Fix the sender's Diag: label before removing it: split time in write_all into
encrypt and socket, or rename it. As written it attributes CPU time to the network.
The earlier throughput benchmark that "ruled out" the cipher at 968 MB/s was run withcargo test --release, so it never exercised the build the app actually ships in dev.
Superseded: is a single TCP flow between these machines worth more than 43 mbps?
Answered above — yes, 907 mbps. Kept for the method.
The SMB comparison has a hole worth checking: SMB multichannel opens several TCP
connections, so 600 mbps aggregate is consistent with a per-flow limit near 43-75 mbps. If
a single plain TCP stream also lands at ~43 mbps, no application change on either side will
help, and the answer is in the network (loss, retries, the Windows adapter or its TCP
settings) — not in this repo or the Rust core.
Zero-install test, Mac as receiver (its address was 192.168.86.226 when this was written):
on the Mac
nc -l 5001 > /dev/nullon Windows: 500 MB over one TCP connection, no Flying Carpet, no crypto
$c = New-Object System.Net.Sockets.TcpClient('192.168.86.226', 5001)
$s = $c.GetStream(); $buf = New-Object byte[] 1048576
$sw = [Diagnostics.Stopwatch]::StartNew()
for ($i = 0; $i -lt 500; $i++) { $s.Write($buf, 0, $buf.Length) }
$s.Close(); $c.Close(); $sw.Stop()
"{0:N1} mbps" -f (500 * 8 / $sw.Elapsed.TotalSeconds)Or with iperf3 (already installed on the Mac), which also answers the multichannel question
directly — iperf3 -s on the Mac, then on Windows:
iperf3 -c 192.168.86.226 -t 20 # one stream: compare against 43 mbps
iperf3 -c 192.168.86.226 -t 20 -P 8 # eight streams: compare against SMB's 600 mbps- One stream ≈ 43 mbps → the app is not involved; it is the path or the Windows host.
Eight streams ≈ 600 would confirm SMB's number came from aggregation.
- One stream ≈ 600 mbps → the network is fine for a single flow, and the remaining
suspect is the Rust sender's socket behavior.
The Apple-side instrumentation has been removed. The sender's Diag: line still needs
fixing or removing — see the header.
Still to check
iOS, and the reverse direction (macOS → Windows), which share this code. Both are sanity runs
now rather than investigations.
Related pending changes
FlyingCarpet (Rust/Android):
- core/src/lib.rs — set_nodelay(true); chunksize() reading FC_CHUNKSIZE (temporary,
for the chunk-size experiment above).
- core/src/sending.rs — the temporary Diag: instrumentation. Remove before release.
- core/tests/throughput.rs — ignored benchmarks (cipher, stream, socket write pattern).
Run with cargo test --release --test throughput -- --ignored --nocapture.
- Android/.../MainViewModel.kt — client.tcpNoDelay = true.
---
Ble Bond Asymmetries
Audit: bonded vs. unbonded BLE behavior on every platform
Superseded in part by docs/bluetooth-field-guide.md (2026-07-25), which consolidatesthis audit with the session's findings and carries the current per-platform matrix. Two
corrections to what follows: the Windows↔Linux hang turned out to be bearer selection,
not GATT caching (see the field guide §3, bug 4); and the "stop removing the GATT service
between transfers" recommendation in §Recommendation below is withdrawn — it would have
required registering the service even while acting as central, and it was never the cause.
The cache findings about Android and Apple below still stand and are still unfixed.
Date: 2026-07-25. Prompted by three Windows↔Linux failures in a row, all of which turned out
to be the same shape: code that works against an unbonded peer and behaves differently, or
not at all, against a bonded one. This audit asks where else that shape exists.
Read docs/windows-ble-gatt-0x8000ffff.md first for the three failures that motivated this.
Why this class of bug is systemic here
Two facts combine badly:
1. Every platform's BLE peripheral removes its GATT service when a transfer ends. iOS
calls removeService(), Linux drops the GATT app_handle, Windows releases the
GattServiceProvider, Android closes and reopens its GATT server. The "Lifecycle status"
matrix in the release test plan tracks this as a feature — and for advertising and
connection teardown it is.
2. Every platform's BLE central caches the peer's GATT database, keyed to the bond. That
is what a bond is partly for: skip rediscovery next time.
So the peer's GATT database legitimately changes between transfers, and the central is
legitimately holding a cached copy of the old one. The GATT spec's answer is the *Service
Changed indication. *Two of our three central implementations receive that notification and
throw it away.
Until now this was masked: Linux deleted its bond after every transfer, so there was rarely a
cached database to go stale. Fixing that (6039d53) makes the bonded path the normal path on
the pairing that gets tested most.
---
Findings
1. Android never invalidates its GATT cache — highest risk
Android/…/Bluetooth.kt:
override fun onServiceChanged(gatt: BluetoothGatt) {
super.onServiceChanged(gatt)
…
outputText("Services changed")
// TODO: should this be enabled? does it cause problems? https://developer.android.com/…
// gatt.discoverServices()
}The callback that exists precisely to handle "the peer's database changed" logs a line and
does nothing. Its one meaningful action is commented out behind an unanswered question.
Android caches the GATT database for bonded devices specifically — discoverServices()
returns the cache rather than re-reading from the peer — and there is no public API to clear
it (the usual workaround is the hidden BluetoothGatt.refresh() via reflection, which
appears nowhere in this codebase).
Predicted symptom: Android as central, second or later transfer with a bonded peer whose
service was removed and re-added — getService(SERVICE_UUID) returns null, or returns a
service whose characteristic handles are stale and whose reads fail.
Status: predicted from code, not observed. Android↔Windows and Android↔Linux hotspot
rows are still unchecked in the test plan, and they are exactly the ones that would show
this. Worth running deliberately: transfer, then transfer again without restarting either
app, with Android receiving both times.
2. Android fails silently when the service or characteristics are missing
Same function, and this is what makes finding 1 dangerous rather than merely annoying:
val service = gatt.getService(SERVICE_UUID)
if (service == null) {
outputText("Did not find service")
return // no bluetoothFailed(), no retry
}
osCharacteristic = service.getCharacteristic(OS_CHARACTERISTIC_UUID) ?: return
ssidCharacteristic = service.getCharacteristic(SSID_CHARACTERISTIC_UUID) ?: return
passwordCharacteristic = service.getCharacteristic(PASSWORD_CHARACTERISTIC_UUID) ?: returnFour exits that leave the transfer waiting forever for a credential exchange that will never
happen. Three of them print nothing at all. There is no timeout behind them.
Compare the other three centrals, all of which fail loudly:
| Platform | Missing service → |
|---|---|
| Linux | ServicesUnresolved error after a bounded retry loop |
| Windows | retry ladder, then a named error naming the likely cause |
| iOS / macOS | cleanUpTransfer() with a user-facing message |
| Android | silent return; hangs |
This is the same silent-hang class as the Linux scan() bug fixed in ed921ac, and it is
the most likely way finding 1 would present to a user: "it just hangs."
This is the highest-value fix in this document — it is small, it is independent of
whether finding 1 is real, and it converts an unbounded hang into a diagnosable error.
3. Apple discards the same notification
iOS/FlyingCarpet/ViewController.swift:
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
print("invalidatedServices: \(invalidatedServices)")
}Implemented, prints, does not re-discover. macOS does not implement it at all — so the
two Apple targets differ from each other, which is its own small inconsistency worth closing
regardless.
CoreBluetooth caches services per CBPeripheral and persists them for bonded peers, so the
same staleness applies. Bluetooth.swift's scan() also short-circuits throughretrieveConnectedPeripherals(withServices:), which returns a peripheral object that may
already carry a populated (and stale) services array.
Severity is lower than Android's for one reason: both Apple targets now handle read
failures in didUpdateValueFor, reporting the error and calling cleanUpTransfer(). A stale
handle surfaces as a failed read with a message, not a hang. That error handling is doing
load-bearing work it wasn't written for.
Minor, same file: didDiscoverPeripheral force-unwraps discoveredPeripheral! — a
different variable than the peripheral parameter it was handed. It is set by the
ViewControllers immediately before, so it works, but it is a crash waiting for a reordering.
4. Windows — checked, and clean on the things I suspected
Recorded so this isn't re-audited later:
- peer_device latch resets correctly. The scan callback bails with "we've already
initiated pairing" if peer_device is already set, which would be a stale-state hazard
across transfers — but BluetoothCentral::new() runs per transfer and rescan() clears it.
Not a bug.
- Cache mode is explicit and correct. GetGattServicesWithCacheModeAsync(Uncached) — the
one central that asks for a fresh read. Since this session it also checks
GattDeviceServicesResult::Status() before trusting the list.
- Windows is therefore the only central of the four that both bypasses the cache and
validates the result.
What remains on Windows is the still-unexplained 0x8000FFFF against a bonded iPhone, and
the re-pair that fails permanently afterward — both already documented, neither reproduced
since.
5. Bond provenance changes cached state — generalize the Linux lesson
The Linux hang in ed921ac came down to how the bond was created: bonding while acting as
central left a device record containing our service UUID; bonding while acting as peripheral
left one without it, and the scan dismissed it forever.
The general statement — worth holding while testing any platform — is that a bond formed
while you were the peripheral leaves you with a different cached view of the peer than one
formed while you were the central, because the record was built from an incoming connection
rather than from an advertisement you filtered for. Flying Carpet reverses these roles between
transfers by design (sender = peripheral, receiver = central), so both provenances occur
routinely, and each platform's discovery code was written and tested against the central-first
one.
That is why the test plan now runs Windows↔Linux from unpaired in both starting orders.
The same doubling is worth doing for Android↔Windows and Android↔Linux.
---
Recommendation
The individual fixes are worth doing, but there is a root-cause fix worth considering first.
Root cause: stop removing the GATT service between transfers
Every finding above exists because the peripheral's GATT database changes between transfers.
If the service were registered once for the app's lifetime and only advertising were
started and stopped, no central would ever hold a stale database, and the invalidation gaps
on Android and Apple would stop mattering.
Windows already separates these — 012d00b added an explicit stop_advertising() while the
service provider lives until drop. The other platforms tear down both together.
The catch, and it is a real one: the SSID and password characteristics would remain
readable between transfers. They are encryption-gated (bonded peers only), but a bonded peer
could read a previous transfer's credentials. Any move in this direction must clear those
characteristic values at teardown rather than leaving the last transfer's in place. That is
a small change, but it is a security-relevant one and needs stating explicitly in review —
the hotspot password is the Noise PSK.
I'd treat this as post-v10: it touches the BLE lifecycle on four platforms, and the release
test plan's entire lifecycle tier was written against the current teardown behavior.
For v10
1. Make Android's four silent returns loud (finding 2). Call bluetoothFailed() with a
message naming the missing service or characteristic. Small, self-contained, and it
converts the most likely field failure from an unbounded hang into a bug report that says
what happened. Do this one regardless of everything else.
2. Run the two Android hotspot pairs twice each, without restarting (findings 1 and 5) —
Android↔Windows and Android↔Linux, Android receiving both legs. These rows are already
unchecked in the test plan; this audit just says what to watch for.
3. Implement didModifyServices on macOS, matching iOS, even if both only log for now.
Costs nothing and removes a gratuitous difference between two targets that share
Bluetooth.swift.
After v10
4. Decide the root-cause question above: keep services registered and clear their values, or
implement invalidation on Android (onServiceChanged → discoverServices()) and Apple
(didModifyServices → re-discover). Keeping the service registered is less code in more
places; the invalidation route is more conservative and testable per platform.
5. Fix the discoveredPeripheral! force-unwrap in didDiscoverPeripheral.
---
Bluetooth Field Guide
Bluetooth field guide
Hard-won knowledge from the 2026-07-25 Windows↔Linux debugging session, plus a cross-platform
audit of where each of the five platforms stands. Read this before touching any BLE code.
Companion docs: docs/windows-ble-gatt-0x8000ffff.md (the running investigation log, in
chronological order) and docs/ble-bond-asymmetries.md (the audit that predicted several of
these). ARCHITECTURE.md covers why Bluetooth is hotspot-only.
---
1. The mental model
Flying Carpet's BLE usage has four independent axes. Almost every bug in this session came
from conflating two of them, or from assuming a platform behaves the same on one axis as it
does on another.
| Axis | Question | Who controls it |
|---|---|---|
| Advertising | can anyone discover me? | the peripheral (sender) |
| Scanning | how do I recognize the peer? | the central (receiver) |
| Bonding | do we have shared keys, and over which transport? | both, at pairing time |
| GATT service | is the service registered, and what does the peer's cache think? | the peripheral registers; the central caches |
The roles reverse between transfers by design — sender = BLE peripheral, receiver = BLE
central — so every device plays both sides, and state created in one role is read back in
the other. That is the source of the whole bug class.
The two axes that actually bite
Bearer selection (LE vs. BR/EDR). A dual-mode peer can be reached over classic Bluetooth
or over LE. Flying Carpet's GATT service exists only over LE. Any stack that picks the
bearer for you can pick wrong, and the failure looks like "connected but no services".
GATT caching. Bonding exists partly so the central can skip rediscovery. That means the
central holds a snapshot of the peer's service database — and Flying Carpet's peripherals
remove their service when a transfer ends, so that snapshot goes stale by design.
---
2. The laws
Learned the hard way. Violate these and you get an intermittent hang weeks later.
1. Never let the stack choose the bearer. Always demand LE explicitly. Linux needs an
L2CAP LE socket to force it; Android needs TRANSPORT_LE, never TRANSPORT_AUTO.
2. A bond's provenance determines its transport. A bond created while you were the
central (you dialed LE) is LE-only. A bond created while you were the peripheral (the peer
dialed in) is dual-transport, because cross-transport key derivation mints BR/EDR keys
from the LE pairing. Same peer, same bond record, opposite behavior on the next connect.
3. Filter on advertisement data, never on resolved-service data. BlueZ's Device1.UUIDs
is "the available remote services" — a cached GATT snapshot — while ServiceData is the
advertisement. They are different sources with confusingly similar names. Windows, Android
and Apple all filter on advertisement data and were never vulnerable; Linux filtered on the
cache and hung on every bonded peer.
4. Never unpair unilaterally. You cannot tell the peer, and Apple platforms cannot clear
their half programmatically at all. A one-sided bond is worse than a bad bond: the peer
still believes it can skip pairing, and encrypts with a key you threw away. Symptoms are
CBError 14 on macOS and an empty GATT service list on Windows.
5. Bonded and unbonded are different code paths on every stack. Every platform's discovery
logic was written and tested against the unbonded path, because that's what a first run
exercises. The bonded path is what users actually hit from the second transfer onward.
6. Read the error string's prefix. br-connection-canceled names its own cause. Three
commits were built on the wrong theory with that string already in the log.
7. Check the Linux Bluetooth history before theorizing. 967ed6b had worked out the
bearer tiebreak, CTKD dual bonds, and the L2CAP-socket cure a month before this session
re-derived them from scratch against a different peer.
8. "Connected" is not a bearer, and it is not a GATT database. BlueZ's Device1.Connected
is bredr_state.connected || le_state.connected — a peer reachable only over classic reads
as connected. Never let it stand in for "we have a usable LE link"; ServicesResolved is
the property that means that. Every guard of the form if !is_connected() { force LE } is
this bug waiting to happen, because it skips the fix in the one case that needs it.
9. Hang up when you're done. A link left open outlives the transfer and is inherited by the
next one in the opposite role, where it silently satisfies every "are we connected?" check
while being the wrong bearer, the wrong direction, or attached to a service that has since
been removed. Dropping the GATT service without dropping the link also strands the peer's
cache: it holds a snapshot of a database that no longer exists, with no Service Changed
coming. Disconnect ≠ unpair — law 4 is about the bond, and this is about the link.
---
3. What actually happened this session
Four distinct bugs, each masking the next. Symptoms in order of appearance:
Bug 1 — Linux deleted the peer's bond after every transfer
Symptom: Windows→Linux, then Linux→Windows: Windows reports AlreadyPaired, thenGetGattServicesWithCacheModeAsync returns success with an empty service list. Retries
don't help. The recovery ladder unpairs, re-pairs, and then fails permanently.
Cause: keep_bond = info.0 == "mac" — Linux removed the bond for any non-macOS peer, on
the premise that "Windows and Android re-pair per transfer." Both halves of that premise were
false: Windows short-circuits on AlreadyPaired, and Android has no removeBond call
anywhere. Windows kept its half and encrypted with an LTK Linux had discarded; the link never
encrypted, so there was no GATT database, which WinRT reports as success-with-nothing.
Fix: 6039d53 — never remove bonds on cleanup. macOS was never special; it was the only
platform whose error message (CBError 14, "Peer removed pairing information") named the
problem out loud.
Bug 2 — Windows conflated "no service" with "no connection"
Symptom: the misleading message above — "Flying Carpet service not found in peer's service
list" — which sent the recovery ladder after the peer's GATT database instead of the link.
Cause: get_services_and_characteristics called .Services() without checkingGattDeviceServicesResult::Status(). On anything but Success the collection is empty, which
is indistinguishable from "connected fine, service genuinely absent."
Fix: 6039d53 — check Status() first, report an empty-but-successful list as its own
condition, and name a one-sided bond as the likely cause in both messages.
Bug 3 — Linux's scan filtered on a property that can never change
Symptom: Windows advertises; Linux sits at "Started Bluetooth scan, waiting for sending
device..." forever. The diagnostic dump shows the peer present at rssi −50, bonded, with a
UUID list that never gains the Flying Carpet service.
Cause, two layers. First, AdapterEvent::DeviceAdded fires only once per device, and
for a bonded peer that once is the pre-seeded cache entry — BlueZ resolves the peer's rotating
private address back to the existing record via the stored IRK. Second and more fundamental,
the property being checked (Device1.UUIDs) is the resolved-GATT-services list, not
advertisement data, so it reflects a cached snapshot taken when the peer had no Flying Carpet
service registered.
Fixes: 573fb87 (use discover_devices_with_changes, which re-emits on property change)
and 6270ac4 (when a bonded peer's cached UUIDs lack the service, connect and ask for the
resolved database instead of trusting the cache).
Bug 4 — the one that mattered: Connect() was dialing classic Bluetooth
Symptom: the probe from bug 3 failing withCould not probe E8:48:…: Bluetooth operation failed: br-connection-canceled.
Cause: br- is BR/EDR. BlueZ's select_conn_bearer prefers the bonded bearer when
exactly one is bonded, otherwise the most recently seen, with BR/EDR winning ties. A bond
created while Linux was the peripheral is dual-transport (CTKD), so both bearers are bonded,
the tiebreak falls through, and classic wins — against a peer that serves GATT only over LE.
The existing LE-bonding socket was gated on address_type() == LePublic, with the comment
"random-address peers (Windows/Android/iOS) always connect over LE anyway." Windows advertises
from E8:48:… (0xE8 = 0b11101000, a static random address), so it was excluded. That
assumption is true only while a peer is unbonded.
Fix: 386f654 — ensure_le_link() raises the LE ACL link with an L2CAP LE socket beforeConnect() for any already-paired peer, regardless of address type. This is the fix that
made it work.
Why the ordering confused everything
The two bond provenances produce opposite outcomes, so the same pair of machines passed or
hung depending on which direction you transferred first:
| First transfer | Linux's bond with the peer | Second transfer |
|---|---|---|
| Windows→Linux (Linux central) | LE-only, made by the L2CAP socket | works |
| Linux→Windows (Linux peripheral) | dual-transport, made by CTKD | br-connection-canceled |
---
3a. The Android sequel — same axis, one layer further in (2026-07-25)
386f654 fixed Connect() picking BR/EDR. It did not fix the case where something else
already established the link, which is what Linux↔Android surfaced.
Symptom: Linux→Android (pairing along the way), then Android→Linux. Android advertises
happily. Linux logs, twice:
Found device
Peer is running Flying Carpet, connecting over Bluetooth...
Already connected to peer over Bluetooth
Bluetooth connection failed; retrying...and never exchanges credentials.
Cause, three defects stacked in the order they fire:
1. Linux never disconnected, in either role. negotiate_bluetooth dropped the
advertisement and the GATT application at the end of a transfer but left the ACL up. There
was no disconnect() anywhere outside scan()'s probe-failure path. So the reverse
transfer began with a live link left over from the previous one.
2. Device1.Connected is bearer-agnostic (law 8), so the leftover link satisfied every
check. find_characteristics took its "already connected" arm, which skipped Connect()
and ensure_le_link(). The bond in this direction is the dual-transport CTKD kind — the
previous transfer had Linux as the peripheral — so the inherited link can be the bearer that
serves no GATT.
3. ensure_le_link() would have been a no-op anyway. Its first statement was
if is_connected() { return } — the exact property that cannot distinguish the two bearers.
The function written to force LE disabled itself in precisely the situation it existed for.
Then device.services() sat in bluer's wait_for_services_resolved (120 s TIMEOUT) and
returned ServicesUnresolved. The retry rung re-ran an identical attempt, because nothing had
torn the link down — hence the log repeating verbatim.
Fixes: ensure_le_link() short-circuits on ServicesResolved instead of Connected and is
now called for every paired peer regardless of connection state (central.rs); the retry rung
disconnects first; and both roles hang up when the exchange is done (bluetooth.rs).
This was already recorded as a symptom in a lib.rs TODO — *"linux can't receive from windows
or android if already paired/connected, service not found. but then it disconnects and next
transfer works"* — including the cure. Law 7 again.
Why Android makes this easy to hit
Android is the only platform whose Flying Carpet GATT service is registered permanently,
not per transfer. Bluetooth.stop() calls initializePeripheral(), which closes the old server
and immediately opens a new one and re-adds the service; MainActivity does the same whenever
Bluetooth is switched on. So BlueZ's cached Device1.UUIDs for an Android peer always contains
our service, and scan() returns it from cache instantly — without waiting for a live
advertisement, and without ever taking the bonded-peer probe path that would have re-resolved
the database. Fast, and wrong-bearer failures surface immediately rather than after a scan.
---
4. Where all five platforms stand
Verified by reading the code on 2026-07-25. ✅ correct · ⚠️ works but fragile · ❌ known gap.
Advertising (peripheral / sender)
| | Mechanism | Stops between transfers | Address type |
|---|---|---|---|
| Windows | GattServiceProvider.StartAdvertising | ✅ explicit stop_advertising() (012d00b), plus a Drop guard for error/cancel paths — WinRT holds its own reference, so dropping ours is not documented to stop it | static random |
| Linux | bluer Advertisement | ✅ drop(adv_handle) after the OS exchange | adapter (usually public) |
| Android | bluetoothLeAdvertiser | ✅ stopAdvertising + GATT server closed | random |
| iOS | CBPeripheralManager | ✅ stopAdvertising + removeService | random |
| macOS | CBPeripheralManager | ✅ same | ⚠️ public + dual-mode flags, unavoidable — the original macOS↔Linux bug |
Scanning (central / receiver) — what the peer is matched on
| | Filter source | Verdict |
|---|---|---|
| Windows | advertisement.ServiceUuids() | ✅ advertisement data |
| Android | ScanFilter.setServiceUuid | ✅ advertisement data |
| iOS/macOS | scanForPeripherals(withServices:) | ✅ advertisement data |
| Linux | DiscoveryFilter + Device1.UUIDs | ⚠️ fixed, but UUIDs is resolved services; the probe path now compensates |
Bearer selection — the axis that caused the outage
| | Choice | Verdict |
|---|---|---|
| Windows | n/a — BluetoothLEDevice is LE by definition | ✅ immune |
| iOS/macOS | n/a — CoreBluetooth is LE-only | ✅ immune |
| Linux | BlueZ select_conn_bearer | ✅ forced LE via L2CAP socket, unbonded and bonded, and now on inherited links too (§3a) |
| Android | TRANSPORT_LE on both paths | ✅ post-bond path fixed in 6b29695 — see §5.1 |
Bond retention
| | After a successful transfer | On failure recovery |
|---|---|---|
| Windows | ✅ keeps (unpairing deliberately disabled) | ✅ one unpair, last resort, warns the user |
| Linux | ✅ keeps (6039d53) | ✅ retry first; remove_device last resort, warns the user |
| Android | ✅ keeps (no removeBond anywhere) | ✅ none |
| iOS/macOS | ✅ keeps (no API to remove) | ✅ none possible |
All five agree on the happy path, and the two failure paths that could still strand a peer now
try everything else first and tell the user what to do on the other device.
GATT service registration and cache invalidation
| | Service lifetime | Central-side cache handling |
|---|---|---|
| Windows | registered per transfer | ✅ Uncached + Status() checked |
| Linux | per transfer (drop(app_handle)) | ✅ connects to re-resolve when cached UUIDs look wrong |
| Android | ⚠️ permanent — stop() calls initializePeripheral(), which reopens the server and re-adds the service | ✅ onServiceChanged → discoverServices(), gated on exchangeComplete |
| iOS | per transfer (removeService) | ✅ shared didModifyServices re-discovers (FlyingCarpetApple 4c59af6, pending an Xcode build) |
| macOS | per transfer | ✅ same shared helper, same caveat |
Every stack that does re-discover depends on the peer sending a Service Changed indication.
Where one isn't sent, no stack learns its cache is stale — see §5.4.
---
5. Outstanding, ranked
1. ~~Android uses TRANSPORT_AUTO immediately after bonding~~ — fixed in 6b29695
Bluetooth.kt's ACTION_BOND_STATE_CHANGED receiver passed TRANSPORT_AUTO to the
post-bond connectGatt while the scan path five hundred lines earlier correctly passedTRANSPORT_LE. The post-bond call fires the moment bonding completes — precisely when CTKD
has just created a dual-transport bond, the exact condition that made BlueZ pick BR/EDR — so
against a dual-mode peer (any desktop, and macOS especially) Android could connect over
classic, which serves no GATT. The direct analogue of the bug that cost the 2026-07-25
session; both paths now pass TRANSPORT_LE.
The Android↔Windows and Android↔Linux hotspot rows in the test plan are the ones that
exercise it.
2. ~~Android fails silently when the service or characteristics are missing~~ — fixed
Four exits from onServicesDiscovered — three printing nothing, none callingbluetoothFailed(), no timeout behind them. Linux errors, Windows retries then errors, Apple
calls cleanUpTransfer(); Android hangs. Any of the bugs above, hit on Android, presents as
"it just hangs" with nothing in the log. Fixed in 6b29695; all four exits now report and callbluetoothFailed().
But that left the callback before it silent, which is worse. A GATT connect that never
succeeds never reaches onServicesDiscovered at all, and onConnectionStateChange's
disconnect branch was a bare Log.i that ignored status — so a failed connect showed up as
the UI going quiet immediately after "Stopped scanning", with the one number that names the
cause thrown away. Observed 2026-07-25 on Linux→Android.
It also never called close(). Android leaks the underlying client if you don't close it after
a disconnect, connectGatt() starts returning status 133 once enough have leaked, andBluetooth.stop() only closes bluetoothGatt — which a failed connect never assigns. So
each failed attempt leaked a client and each retry leaked another, which is the mechanism that
turns one transient failure into a device that stays broken until the app restarts. Both fixed;
the disconnect branch now closes, reports status, and distinguishes the three benign
disconnects (post-exchange, mid-bonding, and a stale overlapping connection) from a real one.
Diagnostic value: if Android goes quiet right after "Stopped scanning", it is failing to
connect, not failing to find the service — those are different callbacks with different causes.
That diagnostic immediately paid for itself, and against the previous entry: on the next run the
connect succeeded and the hang did not reproduce. The failure had moved one stage later, which
is the next item.
2a. ~~A joining Android never set exchangeComplete, so the peer's teardown read as a failure~~ — fixed 2026-07-25
Android→Linux succeeded, then Linux→Android reached Joining flyingCarpet_79e9 — the exchange
was complete, the password was in hand — and two seconds later printed "Did not find the
Flying Carpet service on the peer" and aborted. The peer had done nothing wrong. Linux, having
handed over its credentials, sleeps one second, drops its GATT service and disconnects, exactly
as core/src/linux/bluetooth.rs intends. Android's central saw the Service Changed indication,
re-ran discoverServices(), got 3 services instead of 4, and took the missing-service exit that6b29695 had just wired to bluetoothFailed().
The re-discovery in onServiceChanged (added in b84911b) is guarded on exchangeComplete
precisely to prevent this. The guard never fired, because exchangeComplete was only ever set
on the hosting path (connectToPeer()), and this device was joining. Android is central when
receiving and joins whenever the peer is Linux or Windows — so "Android receives from a PC", one
of the most ordinary configurations there is, ran every transfer with all threeexchangeComplete guards disarmed, including the post-exchange guard added in 3d2545a one
commit earlier. Two of the three would have aborted this transfer; the service-changed one
simply got there first.
Fixed at the flag, in two places, rather than at either call site:
- gotPassword() sets it. That is the joiner's last BLE step, and both joiner roles pass
through it — the central by reading the characteristic, the peripheral by having it written —
so one assignment covers both. Not on an empty password: that means the peer's hotspot isn't
up yet, and the replay this flag suppresses is the retry that recovers.
- bluetoothFailed() returns early when it is set. The teardown arrives as a Service Changed,
then a disconnect, then whatever a read or write already in flight returns, and those reach
three different call sites out of about ten. Once the transfer is on Wi-Fi, no BLE event should
be able to kill it — gate the teardown once instead of auditing every caller.
- Bluetooth.stop() clears it, not just scan(). scan() runs for the central role only, so a
peripheral transfer following a completed one would otherwise inherit true and ignore real
failures for its whole duration. This is the bug the fix above would have introduced.
The lesson is the flag's name. exchangeComplete was set where the host finishes, and
read in three places that all meant "is BLE done with this transfer". Any predicate consumed by
guards that fail open needs to be true for every role that reaches them, and the roles here are
four independent axes (§1) — "hosting" is not "central" is not "sending".
2b. ~~Every GATT request Android issues could be dropped without a trace~~ — fixed 2026-07-25
Visible in the same log, and benign only by luck: the first onServiceChanged (Linux adding
its service) arrived while the discoverServices() from onConnectionStateChange was still in
flight, so two discoveries completed 13 ms apart and both called read(OS). The secondreadCharacteristic() returned false — queue busy — and read() ignored the return. One chain
died silently. It didn't matter because the two chains were identical, but two concurrent walks
of read-OS → write-OS → connectToPeer is not a state this code reasons about, and a silently
dropped GATT request is this project's signature failure mode.
read() had three ways to do nothing at all, none printing anything:
| | why it was silent |
|---|---|
| bluetoothGatt null | bluetoothGatt?.readCharacteristic(...) — the ?. swallowed the whole call |
| characteristic null | unknown UUID fell through a when with no else |
| readCharacteristic() false | return value discarded |
write() had the same three, plus a characteristic!! on the Tiramisu path that would have
thrown rather than reported. And discoverServices() reports busy identically — false return,
no callback, nothing logged.
All of them now print. Reported, not fatal, deliberately: a false return is legitimately
transient here, because the two GATT connections after bonding are meant to coexist (§2) and
can each be walking the exchange, so one finding the queue busy is not grounds for killing a
transfer the other is about to finish. The overlap itself is gone too — both discovery call sites
go through one startDiscovery() that refuses to start a second while one is outstanding, and
clears the flag on connect, on disconnect, and in onServicesDiscovered before its permission
gate so it can never latch on.
The general rule for this platform: every Android GATT call is asynchronous and fallible
synchronously. The boolean or BluetoothStatusCodes return is the only notice you get that the
callback you are about to wait for will never arrive. Discarding it is how a transfer comes to
hang with an empty log — the same shape as §2, one layer down.
2c. ~~A completed transfer flipped the Bluetooth switch off~~ — fixed 2026-07-25
Observed running the release test plan's iOS → Android repeat-transfer row: both transfers
worked, and between them the Bluetooth switch turned itself off — the reliablebluetoothFailed() indicator (§6), firing after a transfer that had finished cleanly. The
logcat trace made the mechanism unambiguous:
1. Fresh pairing mid-transfer means two GATT clients by design (§2): the pre-bond
connect from the scan and the post-bond connect from the bond receiver. bluetoothGatt
tracks only the most recently connected one.
2. Bluetooth.stop() closed only bluetoothGatt — one close()/unregisterApp() in the
log, two clients in existence. The pre-bond client stayed registered and kept the ACL up
(the GATT server logged "Device connected" again immediately after stop()). Law 9,
Android edition.
3. iOS's own teardown (removeService) then delivered a Service Changed indication to the
leftover client — after stop() had cleared exchangeComplete, so the §2a guard was
disarmed. Re-discovery found 9 services instead of 10, took the missing-service exit,
and bluetoothFailed() turned the switch off. The stranded client was still alive a
full transfer later: during leg 2, two clients logged the peer's status-19 disconnect.
Fixed twice over, because the two halves cover different windows:
- Every connectGatt() return is tracked (openConnections) and stop() closes them
all — a closed client delivers no callbacks, which is what makes stop() final.
- tearingDown is true from stop() until the next scan()/advertise() and gates
bluetoothFailed() the same way exchangeComplete does, but for the between-transfers
window that exchangeComplete cannot cover because stop() clears it. It also short-
circuits onServiceChanged and the reconnect-rediscovery path, so a late peer teardown
is a log line ("Ignoring service change after teardown"), not a discovery of a database
with no Flying Carpet service in it.
The lesson generalizes §2a's: a guard cleared at teardown protects nothing that happens
after teardown. Every event the peer can still send after stop() — Service Changed,
disconnect, an in-flight read completing — needs a gate whose lifetime matches the gap
between transfers, not the transfer.
3. ~~The two unilateral-unpair paths violate law 4~~ — fixed 2026-07-25
Windows had eight central.unpair() sites, not one: enumeration failure (both branches),
plus every characteristic read and write. Six of those fired after enumeration had already
succeeded, where the bond is demonstrably fine and the link is up — a failing read is a timing
or peer-side problem that dropping the bond cannot fix, and the peer is left holding a key we
discarded. Those six are gone. The one that remains has positive evidence the bond is at
fault (a reused bond that failed every enumeration attempt) and now tells the user to remove
the pairing on the other device too.
Linux's poisoned-bond remove_device was written for the bearer problem thatensure_le_link() now solves without touching the bond, so it was demoted from first rung to
last: retry once with the bond intact, and only then remove and re-pair, with the same
warning.
4. ~~Android and Apple never invalidate their GATT cache~~ — Android fixed 2026-07-25, Apple fixed in 4c59af6
Android's onServiceChanged now calls discoverServices(), gated on exchangeComplete — the
TODO asked whether enabling it causes problems, and it does if ungated, becauseonServicesDiscovered restarts the credential exchange. That's the same re-entrancy hazardonConnectionStateChange already guards the same way.
Apple was once written up as fixed when it was not (verified stub at 328dfc8); the real fix
is FlyingCarpetApple 4c59af6 (2026-07-25): a shared didModifyServices inApple/shared/Bluetooth.swift re-discovers when our service is among the invalidated ones, and both
targets' delegate methods now call it (macOS previously did not implement the method at all).
Caveat: written on Windows, not yet compiled — verify with an Xcode build on both targets
before treating this as closed.
Remaining limitation, by design: this only helps when the peer sends a Service Changed
indication. If it doesn't, neither stack learns its cache is stale. Android's only other lever
is the hidden BluetoothGatt.refresh() via reflection — non-public API, breaks across
versions, discouraged by Play policy — so the gap is documented rather than papered over.
5. Still open: nothing blocking
The 0x8000FFFF enumeration failure against a bonded iPhone (§3, the original subject ofdocs/windows-ble-gatt-0x8000ffff.md) remains unexplained and unreproduced since 2026-07-24.
The retry rung covers it; the unpair rung behind it is now the only unpair left in the
codebase.
---
6. Debugging playbook
| Symptom | Most likely cause | First thing to check |
|---|---|---|
| Connected, but zero services | link never encrypted — one-sided bond | is the peer still bonded? bluetoothctl paired-devices |
| Connected, services present, ours missing | stale GATT cache, or peer hadn't registered it yet | did the peer register the service before you connected? |
| Error containing br- | classic bearer chosen for an LE-only service | force LE; check bond provenance |
| Scan never finds an advertising peer | filtering on cached rather than advertised data | does the filter read advertisement data? |
| Works on first pairing, fails on reuse | bonded-vs-unbonded divergence | test both bond provenances (see below) |
| "Already connected", then a ~120 s stall and a retry that repeats verbatim | a link inherited from the previous transfer, on the wrong bearer (§3a) | did either side disconnect() last time? is the guard checking Connected where it means ServicesResolved? |
| Android goes quiet right after "Stopped scanning" | the GATT connect failed — not service discovery, which reports every exit | adb logcat -s Bluetooth for the status in onConnectionStateChange; 133 after repeated attempts means leaked clients, so restart the app |
| A BLE error seconds after the credentials were exchanged — "services changed" then a missing service, or a disconnect | the peer's deliberate teardown, not a failure; a post-exchange guard that isn't set for this device's role (§2a) | is exchangeComplete set on this role's path? does the peer remove its service and disconnect after handing over credentials? |
| Bluetooth switch flips itself off and the peer-OS chooser reappears | bluetoothFailed() ran — that is enableBluetoothUi(false), and nothing else in the app does it | a reliable "a BLE callback failed the transfer" indicator. Since §2c it can only fire during a transfer — a flip after a finished one is a §2c regression (a leftover client or a disarmed tearingDown gate) |
| macOS: CBError 14 | peer deleted its half of the bond | law 4 |
| Windows: 0x8000FFFF on enumeration | unresolved; retry ladder | docs/windows-ble-gatt-0x8000ffff.md |
Always test both bond provenances. From fully unpaired, run A→B then B→A; then unpair
both sides and run B→A then A→B. These are different code paths and only one of them was
passing for the entire v10 cycle.
Useful instruments: sudo btmon (raw HCI — the only way to see which bearer and what's
actually in the advertisement), bluetoothctl info <addr> (what BlueZ believes),adb logcat -s FlyingCarpet (Android), cargo tauri dev stdout on both desktops. UI logs
alone were insufficient for every bug in this session.
---
Hardcoded Delays Audit
Audit: hardcoded delays across both repos
Date: 2026-07-25. Scope: every sleep, delay, Task.sleep, Thread.sleep,asyncAfter, postDelayed, and bounded timeout in FlyingCarpet (Rust, Kotlin, JS) andApple/ (Swift). 45 sites found, plus 4 commented-out ones.
Verdict
The situation is better than it looks, and the problem is concentrated, not spread out.
Of 45 delay sites, 31 are legitimate — retry backoff, bounded timeouts on genuinely
asynchronous OS state, and protocol-defined announce intervals. 2 are test-only.
12 are fixed settling delays: "wait N seconds and hope the other side is ready."
Those 12 are not scattered. Nine of them are in the BLE credential exchange, in all
three languages, and they exist because that exchange has one structural gap: *it is a
sequence of GATT reads and writes with no completion signal*, so every platform independently
bolted a timer onto it. The other three are the tail of the file-transfer confirmation
handshake, which has the same shape of gap.
So this is not "hacky fixes all over the place." It is two protocol gaps, papered over
independently six times. That's a much more tractable thing to fix — and also a reason not
to fix it by deleting sleeps one at a time.
Nothing here is a v10 blocker. Recommendations are split accordingly at the end.
---
The 12 fixed settling delays
Ranked by how confident I am that they're papering over something, and what they cost.
1. The BLE credential exchange — 9 sites, ~4–6 seconds per transfer
| File | Delay | Stated reason |
|---|---|---|
| core/src/linux/central.rs find_characteristics | 2 s | none — bare sleep before bonding |
| core/src/linux/central.rs exchange_info ×4 | 1 s each | none — one after each characteristic read/write |
| core/src/linux/bluetooth.rs negotiate_bluetooth | 1 s | "Removing GATT service" — hold it open a moment longer |
| core/src/windows/bluetooth.rs negotiate_bluetooth | 1 s | "keep everything in scope until peer has had a chance to read the password" |
| Android/…/Bluetooth.kt onCharacteristicRead | 1 s | peer's SSID came back empty; sleep and re-read |
| Android/…/Bluetooth.kt onConnectionStateChange | 1.6 s | before discoverServices() |
This is the delay observed in testing on 2026-07-25: a Linux central spends 4 s (joining)
to 6 s (hosting) in pure sleeps before the hotspot is even created, with nothing on screen.
Three distinct things are going on:
(a) "Empty SSID means not ready yet" is a polling protocol. The peripheral publishes an
empty string until its hotspot exists; the central reads, sees "", waits, and reads again.
Android sleeps 1 s and re-reads. iOS and macOS schedule a re-read (2 s / 1 s). All three are
working around the same thing: the SSID characteristic never notifies. A GATT
notify/indicate on that characteristic would delete this entire category, on every platform
at once — the central would be told, rather than asked to guess. That is the single
highest-value change in this document.
(b) Post-operation settling on Linux. The four 1 s sleeps in exchange_info and the 2 s
before bonding have no comment and no stated cause. The adjacent comment explains
something else (that WriteOp::Request is used because iOS ignored unconfirmed writes),
which suggests these were added while chasing the same iOS problem and then never revisited
after the real fix landed. They are the most likely to be pure superstition — and also the
riskiest to remove blind, because if any of them is load-bearing it will only show up
against Apple peers.
(c) Hold-open-before-teardown. Windows and Linux both sleep 1 s before dropping the GATT
service so the peer can finish reading. But both already wait for an event that says the
read happened (PeerReadPassword / BluetoothMessage::Password) immediately before
sleeping. Worth checking whether the event already guarantees what the sleep is buying; if
so these are free to delete.
Android's 1.6 s before discoverServices() — this entry was wrong, corrected 2026-07-27.
The original claim here was that its own comment admitted the delay wasn't the fix:
// this was the reason android couldn't connect to macOS? no, was the setLegacy(false).…and that it was therefore a sleep whose author recorded, in the code, that it didn't solve
the problem it was added for.
That reading was mistaken, and the comment invited it. 1600 ms is Nordic's
Android-BLE-Library constant: after onConnectionStateChange on a bonded device their
guidance is to wait ~1.6 s before discoverServices(), so the Service Changed indication and
key exchange complete first — discover earlier and you can enumerate a stale GATT database.
The value matches exactly and is near-certainly copied from there. The macOS note was
answering a different question that happened to be asked on the same line; setLegacy(false)
was the macOS fix and has nothing to do with the delay.
So the delay has a real, documented rationale, and it is not superstition. It is still
arguably removable — Nordic report the problem was primarily Android 6 and unnecessary by 7-8,
while minSdk here is 29 — but that is a deliberate change needing its own device test
(a first-time pairing against a bonded peer, the case the delay protects), not a cleanup.
Flying Carpet bonds, and nothing in Bluetooth.kt invalidates the GATT cache, so the stale-
database failure mode has no other mitigation. Left in place; the code comment now records
all of this.
Lesson worth keeping: a confusing comment adjacent to a magic number is enough to get the
number itself convicted. Check whether a constant matches a known upstream value before
calling it superstition.
2. Android sleeps on the Bluetooth callback thread — a real bug, not just a delay
Android/…/Bluetooth.kt, in onCharacteristicRead:
outputText("Could not read peer's WiFi characteristic. trying again...")
Thread.sleep(1000)
read(SSID_CHARACTERISTIC_UUID)This blocks the BLE callback thread for a full second. Every other GATT callback queued
behind it — including ones for an unrelated in-flight operation — is stalled.
The Apple side hit this exact problem and documented the fix, in both ViewController.swift
files:
// scheduled rather than slept: this callback runs on the bluetooth queue, and sleeping// here stalls every other callback behind it…and uses queue.asyncAfter instead. Android should do the same — post a delayed
runnable rather than sleeping. Same bug class, one platform learned it and the knowledge
didn't cross over. Thread.sleep(1600) before discoverServices() is in the same callback
context and has the same problem.
This is the one item in the audit I'd call an outright defect rather than a smell.
Status: fixed in 5cec37c ("get off the binder thread"), which replaced both sleeps withhandler.postDelayed. Note the defect was sleeping on the binder thread, not the existence
of the 1.6 s wait — see the correction in section 1. The scheduled version keeps the wait and
stops blocking other callbacks, which is the intended end state, not a compromise.
One thing the move to postDelayed introduced: a check made when the runnable is scheduled
is stale by the time it fires. exchangeComplete was tested before the 1600 ms delay but
not inside it, so a discovery could still run against a peer that had torn its service down
during the wait, producing a spurious "try unpairing" message in the transfer output. Fixed by
re-checking inside the runnable. Worth remembering for any other sleep converted this way.
3. The end-of-transfer confirmation — 3 sites, and the code says so
core/src/receiving.rs, twice, verbatim:
// TODO: ugly hack to get around lifetime issue? sending end didn't receive this last
// reply when calculating hash of large file.
sleep(time::Duration::from_secs(1)).await;The author's own note, question mark included. The surrounding protocol is a "double
confirmation" at the end of each file: the sender writes a u64, the receiver replies, the
sender replies again. It has no clean termination condition, so all three platforms bolt a
timer on:
- Rust receiver, end of receive_file: timeout(2 s) on the final read, then prints
"Didn't receive confirmation" and carries on
- Swift receiver, end of receiveFile: a detached task that sleeps 2 s and calls killIt()
- Rust receiver, in check_for_file: the two 1 s sleeps above, after replying "I don't have
this file" — held open because the sender missed that reply while it was busy hashing a
large file
None of these is dangerous — the file data is already written and verified by then, and the
timeout path is handled — but three independent timers around one handshake is the signature
of a protocol that doesn't say when it's finished. Cheapest honest fix is to define an
explicit end-of-transfer message rather than inferring completion from silence. That is a
wire-format change, so it is v11 work, not v10.
4. iOS joinHotspot() — unbounded retry loop
iOS/FlyingCarpet/ViewController.swift:
while true {
try await NEHotspotConfigurationManager.shared.apply(config)
try? await Task.sleep(nanoseconds: 3_000_000_000)
if Task.isCancelled { throw TransferError.UserCancelled }
if await isConnected() { break }
NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: self.transfer.ssid)
}The 3 s sleep is fine — association genuinely continues after apply() returns. The loop
has no attempt limit: it exits only on success or user cancellation. CompareTransfer.swift's peer-IP wait, which bounds itself at 120 iterations and throwsCouldNotJoinNetwork. Cancellation does provide an escape, so this is a consistency and
user-experience issue rather than a hang, but it should count attempts like its neighbour
does.
---
The 31 legitimate ones
Recorded so a future audit doesn't re-litigate them.
Retry backoff after a failed operation (7): TCP connect retries in core/src/lib.rs,MainViewModel.kt, and Transfer.swift (2 s each — three platforms, consistent); nmcli retry in
con upcore/src/linux/network.rs (1 s); hotspot join retry incore/src/windows/network.rs (2 s) and Transfer.swift (2 s); GATT enumeration retry incore/src/windows/bluetooth.rs (1 s, matching Microsoft's published guidance —
see docs/windows-ble-gatt-0x8000ffff.md).
Polling OS state that fires no event (6): gateway discovery after joining a hotspot
(200 ms on both Windows and Linux); characteristic-discovery retry on Linux (2 s);
transfer-cancellation wait in the Tauri command (100 ms); discovery-cancellation poll incore/src/discovery.rs (100 ms); peer-IP wait in Transfer.swift (1 s, bounded at 120).
Protocol-defined announce intervals (6): DISCOVERY_INTERVAL_MS in discovery.rs,Discovery.kt, and Discovery.swift. These are the wire protocol, identical across
platforms by design — not delays in the sense this audit is about.
Bounded timeouts on external events (8): LE bonding socket, 60 s (waits on a human
confirming a PIN); final-confirmation read, 2 s; discovery socket receive, 100 ms; firewall
rule verification, 10 × 500 ms (netsh runs in a separate elevated process, so the result is
genuinely asynchronous — and it's a bounded poll with a warning on failure, which is the
right shape); iOS local-network permission probe, 3 s; Network.swift connection-waiting
cancel, 30 s (with a comment explaining precisely why the bound exists); Network.swift
generic connect timeout; Swift discovery poll, 50 ms.
Correctly scheduled rather than slept (2): the iOS and macOS SSID re-reads viaqueue.asyncAfter. These are the pattern Android should copy.
---
Test hygiene finding (unrelated to delays, found on the way)
core/src/linux/network.rs has two tests that manipulate real system network state and
neither is #[ignore]d:
- start_and_stop_hotspot — creates a real NetworkManager hotspot named
flyingCarpet_1234, sleeps 5 s, tears it down
- join_hotspot — calls the real join path, sleeps 20 s, tears down
So cargo test on a Linux machine tries to reconfigure the network, needs polkit auth, and
can leave a stale flyingCarpet_* connection behind — which is exactly the residue that
issue #51 is about.
Their Windows counterparts (join_hotspot, check_for_firewall_rule) are both#[ignore]d; the second was marked in this very branch (dbdfbb1) for the identical
reason. The Linux pair was missed. This one is a two-line fix and worth doing before
release, since it can actively dirty a test machine.
---
Recommendations
Before v10
1. Mark the two Linux hardware tests #[ignore], matching the Windows pattern. Two
lines, no behavior change, stops cargo test from dirtying a Linux box.
2. Fix Android's Thread.sleep on the BLE callback thread — replace both with a posted
delayed runnable, copying the Apple approach. This is a defect with a known-correct fix
already implemented on another platform, and it's in the credential-exchange path that
has been the source of most release-test failures.
3. Bound the iOS joinHotspot() loop with an attempt limit, like its neighbour.
Everything else should wait. Sleeps in the BLE path are precisely the code that current
hardware testing is validating, and removing them mid-test-cycle invalidates results for no
user-visible gain.
After v10
4. Add notify/indicate to the SSID characteristic. This is the root fix: it removes the
"read, see empty, wait, re-read" pattern from Android, iOS, and macOS simultaneously, and
is the reason several of the settling delays exist at all. Wire-compatible in the sense
that it adds a capability rather than changing existing bytes — but it touches all three
implementations and needs the usual cross-platform care.
5. Audit the five unexplained Linux BLE sleeps individually, on hardware, against an
Apple peer specifically — that's the peer they were most likely added for. Remove them
one at a time, not as a batch, so a regression identifies itself.
6. Check whether the two hold-open-before-teardown sleeps are already covered by the
read events that immediately precede them.
7. Give the transfer an explicit end-of-transfer message instead of inferring completion
from silence, retiring the three confirmation timers and the two "ugly hack" sleeps.
Wire-format change — v11, per the version rule in CLAUDE.md.
Not recommended
Do not do a sweeping "remove all the sleeps" pass. Several of these are load-bearing in ways
that only show up against one specific peer platform, the failure mode is an intermittent
hang rather than a clean error, and the code has no automated coverage at this layer — every
regression would be found by hand, on hardware, weeks later.
---
Post V10 Maintenance
Post-v10 Maintenance Backlog
Work deliberately deferred until after v10 ships, plus findings from post-release field
reports. None of it is blocking. The housekeeping items touch code that the v10 release
testing already covers, so doing them mid-release would invalidate hardware testing for no
benefit; revisit once docs/v10-release-test-plan.md is signed off.
---
1. Consolidate the duplicate windows crate versions
The dependency tree currently carries three semver-incompatible versions of thewindows crate (windows-rs):
| Version | Pulled in by | Ours to control? |
|---|---|---|
| 0.44.0 | wifidirect-legacy-ap 0.4.0 → flying-carpet-core | Yes — our own crate (github.com/spieglt/wifidirect-legacy-ap), ~193 LOC, last published Feb 2023 |
| 0.58.0 | flying-carpet-core directly (core/Cargo.toml) | Yes |
| 0.61.3 | Tauri stack (tao, tauri-runtime, tauri-runtime-wry, webview2-com) | No — moves on Tauri's release schedule |
Why this is not urgent. Multiple semver-incompatible versions are normal in Rust and
cargo supports them by design. The only real cost is build time and some binary size (thewindows crate is heavily feature-gated, so far less than the version count suggests).
They only actually break something when a windows type has to cross a crate boundary —
e.g. an attempt to reach Tauri's ICoreWebView2 from our code failed to type-check until a
matching webview2-com/windows pair was added as a direct dependency. No current feature
needs that.
Why it's worth doing eventually. The 0.44.0 pin dates to February 2023 and will
eventually block something. It is also the easiest to fix, since we own the crate.
Suggested order (lowest risk first):
1. Bump wifidirect-legacy-ap to a modern windows and publish 0.5.0. Separate repo,
~193 lines, isolated from the app — easy to verify on its own.
2. Bump core from 0.58 → 0.61 and fix the call sites. This is the risky half:
windows-rs has breaking changes across those versions (error/Result handling,
BOOL vs bool, the Param/IntoParam traits, the core::Interface reorganization),
and core/src/windows/{bluetooth,peripheral,central,network}.rs lean on it heavily for
GATT and hotspot work. Budget time to re-run the Windows BLE + hotspot hardware tests
afterward.
Caveat — don't chase Tauri. "Match whatever version Tauri uses" is a treadmill: Tauri
will move to 0.62+ and we will diverge again. The durable goal is retiring the 0.44 pin;
landing on the same version as Tauri is a pleasant side effect, not the objective.
---
2. Mobile transfers die when the app is backgrounded or the screen sleeps
Reported by users and reproduced informally. Neither mobile app does anything to keep
running when it loses the foreground — this is not a bug in the transfer code, it's a
missing platform integration on both sides. Confirmed by audit, 2026-07-26:
- Android — AndroidManifest.xml declares no <service> at all, no FOREGROUND_SERVICE
permission, no WakeLock, no WifiLock, and no FLAG_KEEP_SCREEN_ON. The only lock
anywhere is the MulticastLock in Discovery.kt:183. Transfers run in bare
GlobalScope.launch coroutines off MainViewModel (MainViewModel.kt:366, :566,
:906), with UI callbacks bound to one Activity instance (MainActivity.kt:240-244).
- iOS — Apple/iOS/FlyingCarpet/Info.plist has no UIBackgroundModes key.
SceneDelegate.swift:44 sceneDidEnterBackground is still the empty Xcode template. No
beginBackgroundTask, no isIdleTimerDisabled anywhere in Apple/.
Screen sleep and app-switch are the same event on both platforms. Display timeout stops
the Activity on Android and backgrounds the scene on iOS. Any fix has to cover both, and the
screen-timeout case is almost certainly the bulk of the reports: user starts a transfer, puts
the phone down, the display sleeps, the peer sees a dead socket.
iOS: structurally cannot continue in the background
There is no way around this, and it should be treated as a constraint to communicate rather
than a bug to fix. Apple DTS on this exact scenario (forums/thread/715118):
"The thing to keep in mind with networking in iOS is that the background/foreground state
isn't key, but rather the suspended/running state. Networking works just fine as long as
your process is running. Once it's suspended, everything just stops." … "When the app on
Device2 gets suspended, its TCP connections will likely be closed immediately." … "My
general advice is that, when your app moves to the background you should shut down your
networking, resuming it when you come back into the foreground."
No background mode covers a raw peer-to-peer TCP transfer. The list is audio, location, voip,
external-accessory, bluetooth-central, bluetooth-peripheral, fetch, processing. bluetooth-central
would only keep the BLE credential exchange alive, never the Wi-Fi transfer; NSURLSession
background transfers only work against an HTTP server. In hotspot mode theNEHotspotConfiguration association to a no-internet network may also be dropped once
suspended, so even resuming won't reliably find the peer.
What can be done:
1. UIApplication.shared.isIdleTimerDisabled = true for the duration of a transfer. The
single highest-value change on iOS — it eliminates the dominant cause outright. Roughly
five lines in toggleUI(transferRunning:), set and cleared symmetrically with the rest of
the transfer UI state.
2. beginBackgroundTask around the transfer buys ~30 seconds (iOS 13 cut the
from-foreground grant to about that). Enough to survive a glanced-at notification or a
quick app switch and back; nowhere near enough for a real transfer.
3. Fail loudly instead of hanging. sceneDidEnterBackground currently does nothing, so
the user watches a frozen progress bar and then gets a generic socket error. Wire it to the
running Transfer and emit something explicit — "Flying Carpet was moved to the
background; iOS suspends apps and cannot continue transfers there." Turns a mystery bug
report into a comprehensible limitation.
Android: fixable, and worth fixing
Android doesn't force-suspend the process, it kills it. Once no Activity is visible the
process has no foreground component and drops to a cached process, eligible for kill at
any moment under memory pressure. That explains the intermittent, device-dependent character
of the reports. On top of that, screen-off puts the Wi-Fi radio into power save and lets the
SoC suspend.
Checked and found false: AOSP's current WifiNetworkFactory validates foreground status
only at request time (isRequestFromForegroundAppOrService in acceptRequest()); there is
no continuous importance monitoring that revokes an established WifiNetworkSpecifier
connection when the app backgrounds. So the joined hotspot is not proactively torn down — the
process dying is what kills it. Don't waste time chasing a framework teardown that isn't there.
Four changes, in descending value-per-effort:
1. FLAG_KEEP_SCREEN_ON while a transfer runs. One line, alongside the existing
orientation lock at MainActivity.kt:317. Same reasoning as iOS: mostly makes the problem
not happen.
2. A foreground service. The actual fix. Type connectedDevice is the right one, and
its runtime prerequisite is already satisfied — it accepts CHANGE_NETWORK_STATE,
CHANGE_WIFI_STATE, or CHANGE_WIFI_MULTICAST_STATE, all three of which the manifest
already declares. Needs FOREGROUND_SERVICE + FOREGROUND_SERVICE_CONNECTED_DEVICE, plus
POST_NOTIFICATIONS for the notification on API 33+. Declaring a type is mandatory at
targetSdk = 37 anyway. Starting it from the Start button is a legal foreground start, and
the notification doubles as progress display and a cancel action. (dataSync also matches
the description but carries Android 15's extra restrictions; connectedDevice is cleaner.)
3. A PARTIAL_WAKE_LOCK for the transfer's duration. A foreground service does not
keep the CPU awake — a commonly missed point. With the screen off the SoC suspends and the
transfer threads stop.
4. A WifiLock in WIFI_MODE_FULL_HIGH_PERF. Documented as keeping Wi-Fi at high
performance "even when the device screen is off." Do not use
WIFI_MODE_FULL_LOW_LATENCY here — AOSP documents it as activating only when the app
"is running in the foreground" and "the screen is on," precisely the case that needs no
help. HIGH_PERF is deprecated but is the mode that covers screen-off. This matters for
shared-network discovery too: the MulticastLock stops the driver filtering multicast, but
does nothing about the radio entering power save.
Sequencing. Items 1, 3, and 4 are small and independently shippable; do them first. Item 2
is a real refactor, not a drop-in: MainViewModel currently owns the sockets and calls back
into a specific Activity for displayQrCode, promptForPassword, and cleanUpUi. Transfer
state has to move into the service with the UI observing it, rather than the transfer holding
an Activity reference.
---
3. Android share sheet
Long-standing TODO at MainActivity.kt:795. Sending a file would start from the sharing app
rather than from Flying Carpet.
Manifest. Add an intent filter to MainActivity for ACTION_SEND andACTION_SEND_MULTIPLE with category.DEFAULT and mimeType="/". Setandroid:launchMode="singleTop" and override onNewIntent — otherwise sharing into the app
while a transfer is running spawns a second MainActivity with a fresh ViewModel while the
first still holds the hotspot and sockets.
The refactor that makes it work. getFilePicker() (MainActivity.kt:66-108) has the
entire "we have files, now proceed" sequence inlined in its result callback: buildDocumentFiles, open InputStreams, then either BLE advertise/scan or connectToPeer().
Extract that body into something like stageFilesForSending(uris: List<Uri>) so the picker
and the share intent share one path. Read the URIs from EXTRA_STREAM viaIntentCompat.getParcelableExtra / getParcelableArrayListExtra — the untyped overloads are
deprecated at API 33+.
UX: don't auto-start. In hotspot mode the user still has to choose peer OS and connection
mode. Cleanest flow: the share intent lands, files are staged, the mode toggle flips to Send,
the folder checkbox hides, the Start button reads "Start" instead of "Select Files", and the
Start handler skips the picker when staged URIs exist. Folder sends don't apply — the share
sheet hands over files, so sendFolder stays false and a multi-file share maps onto the
existing multi-file path with empty filePaths.
Two real gotchas:
- Shared URIs are not ACTION_OPEN_DOCUMENT URIs. DocumentFile.fromSingleUri mostly
works on them by accident — DocumentsContract.Document.COLUMN_DISPLAY_NAME and
COLUMN_SIZE happen to be the same column strings as OpenableColumns.DISPLAY_NAME/SIZE
— but it isn't guaranteed, and file:// URIs (still shared by some apps) fail outright.
sendFile depends on file.name (Send.kt:69, which throws "Could not get filename" on
null) and file.length() (Send.kt:13, :20, :33, :81). Worth a small
name/size/openStream abstraction, or at minimum a file:// → DocumentFile.fromFile branch.
- The share grant is Activity-scoped and revoked when the Activity finishes; unlike an
OPEN_DOCUMENT grant it cannot be persisted. Opening all the InputStreams eagerly (which
the current code already does) covers most of it, but hashFile (Utilities.kt:114-116)
reopens the URI mid-transfer during the resume/skip check. If the Activity is gone by
then, that fails — another argument for the foreground service in §2.
iOS is a different design entirely — see §4.
---
4. iOS share menu
Worth doing, but do not assume it mirrors the Android design. Two iOS-specific
restrictions rule out the obvious approach, and between them they mean there is no single
mechanism that both handles multiple files and lands the user in the app. Verified
2026-07-26.
Restriction 1: a Share Extension cannot open the containing app
The natural design — extension stages the files, then opens Flying Carpet to run the transfer
— is not supported and is an App Store risk. From Apple's Frameworks Engineer on
forums/thread/773342:
"There's no supported way for you to launch your app directly from App Extensions, except
Today and Widgets (which requires OpenURLIntent and is available to processes that can useApp Intents), with the APIs currently available."
NSExtensionContext.open(_:) is documented for Today extensions only, and the responder-chain
walk to reach UIApplication.openURL is the exact Objective-C runtime bypass Apple calls out
as unsupported. Apple offers no sanctioned alternative — their suggestion is a local
notification, or a Feedback Assistant enhancement request. Don't build on this.
Restriction 2: the document-types route is effectively single-file
Declaring CFBundleDocumentTypes + LSSupportsOpeningDocumentsInPlace puts a "Copy to Flying
Carpet" action in the share sheet, and that route does launch the app, delivering the file
through scene(_:openURLContexts:). No extension, no app group, no new target — much cheaper
than a Share Extension. But open-URL requests are atomic and don't carry multiple URLs; iOS
delivers only the first file even when several are shared. Multi-select in Photos may not
offer the app at all.
The resulting shape
| | Multi-file | Opens the app | Placement | Cost |
|---|---|---|---|---|
| Document types (CFBundleDocumentTypes) | No — first file only | Yes | Lower "actions" row | Info.plist only |
| Share Extension | Yes | No — user must switch manually | Top app row | New target, app group, provisioning, App Store resubmit |
Suggested order. Start with document types — it is an Info.plist change, it covers the
single-file case (share one video to Flying Carpet, which is likely the common one), and it
actually opens the app. Add the Share Extension later for multi-select, accepting that it can
only stage files and show "N files ready — open Flying Carpet to send," leaving the user to
switch apps.
If/when the Share Extension is built
- Stage into an App Group container, never the Documents directory. emptyDocsDir()
(iOS/FlyingCarpet/ViewController.swift:579) sweeps .documentDirectory wholesale and runs
both at launch and before every transfer (:65, :455) — anything the extension dropped
there would be deleted before it could be sent. Files must land in the shared container and
be adopted deliberately into transfer.fileList on next foreground.
- Sweep the container too. The extension has no way to know whether the user ever opened
the app, so staged files accumulate. Extend the emptyDocsDir() discipline to the group
container.
- Don't reuse the Live Photo staging pattern in the extension. The PHPicker path reads a
whole asset resource into an in-memory NSMutableData
(iOS/FlyingCarpet/ViewController.swift:~161-183); extensions run under a far tighter memory
budget than the host app. Use loadFileRepresentation + a filesystem copy, which streams.
- Scope NSExtensionActivationRule deliberately. TRUEPREDICATE offers Flying Carpet for
text and URLs, which it can't send. Use the
NSExtensionActivationSupportsFileWithMaxCount / ImageWithMaxCount / MovieWithMaxCount
keys with generous counts.
- Provisioning is the hidden cost. A new bundle ID
(dev.spiegl.FlyingCarpet.ShareExtension), an App Group entitlement on both targets, and its
own profile — on top of DEVELOPMENT_TEAM already being deliberately blank in
project.pbxproj (see Apple/CLAUDE.md). The iOS app currently declares no URL scheme and
no app group, and shipped as 10.0.0 build 1, so this means a fresh App Store submission.
macOS could take the same treatment via a Share Extension, but Macs have drag-and-drop andNSSharingService already; lower priority.
---
5. Dependency housekeeping notes
The 2026-07-23 Dependabot sweep is done and its resolved-alert detail has been dropped from
this doc. What's still worth carrying:
- glib (alert #26) remains open, blocked upstream. Reached via atk 0.18.2 →
gtk 0.18.2 → muda → tauri. Even Tauri 2.11.1 still pins the gtk-rs 0.18 family and
the fix needs 0.20. Linux/GTK-only, so Windows and macOS builds are unaffected. Re-check
whenever Tauri is next upgraded.
- rand 0.7.3 is also in the lock and in an affected range, but it comes from
phf_generator as a build-time dependency — not in the runtime graph, not independently
updatable. Expect it to keep showing up.
- The desktop frontend's JS/CSS is not covered by Dependabot. Flying Carpet/src/deps/
(bootstrap.min.css, qrcode.js) is vendored with no package.json, so it must be
refreshed by hand.
- Toolchain is now rustc 1.97.1 (up from 1.85.0). If rustup update stable fails on the
deprecated rls-preview component, remove it with
rustup component remove --toolchain stable rls-preview and retry.
- rust-analyzer proc-macro crashes are version skew, not code. all proc-macro server on every
workers have exited#[tauri::command] and #[derive(...)] was rustc drifting
~17 months behind the auto-updating rust-analyzer in the VS Code extension, breaking the
proc-macro bridge ABI. Fixed by the toolchain upgrade; restart the server afterward. If it
recurs, suspect toolchain/rust-analyzer skew before suspecting the code.
---
6. Windows code signing via SignPath Foundation
Unsigned .exe/.msi means every Windows user meets the SmartScreen "Windows protected your
PC" wall on download. It also has a second cost that only became obvious while fixing #129:
the firewall rule prompt is a UAC prompt on an unsigned binary, so it says *Unknown
publisher* rather than the author's name — on the one dialog we most want users to trust.
Route: SignPath Foundation, which signs open-source projects for free using Sectigo
certificates. Flying Carpet qualifies on every published condition: OSI-approved license with
no commercial dual-licensing (GPL-3.0-only), public repository, actively maintained, and
already shipping releases in the form to be signed.
Alternatives, both rejected on cost/benefit rather than capability:
- Azure Trusted Signing (rebranded Azure Artifact Signing in 2026), $9.99/mo for 5,000
signatures, open to verified US/CA/EU/UK businesses and self-employed individuals. The
disqualifier is that nothing is exportable: lose eligibility or leave Azure and signing
stops, with no certificate to carry elsewhere.
- A traditional OV certificate, ~$200-400/yr, and since the 2023 CA/Browser Forum rules
the private key must live on a FIPS 140-2 Level 2 token or HSM — no more .pfx on disk.
Order of operations
Apply to the Foundation first. The application requires a project that already publishes
releases, and it issues the organization-id, project-slug, and signing-policy-slug that
the workflow has to reference — so the workflow cannot be finished before acceptance.
Constraints that shape the CI design
- GitHub-hosted runners only. For OSS projects, every job leading up to the signing
request must run on GitHub-hosted agents. Self-hosted is permitted only for non-OSS.
- Origin verification dislikes caches. Build settings must be fully determined by config
under source control with no manual overrides in the job, and builds must not be
contaminated by previous builds' caches. So no Swatinem/rust-cache here. A cold
cargo tauri build on windows-latest runs ~20 minutes, which is acceptable for something
that only fires on a version tag. Confirm the exact caching stance against the assigned
policy.
- This repo has no CI at all — .github/ holds only FUNDING.yml — so this is the first
workflow, and nothing existing depends on it.
The Tauri wrinkle — decide this deliberately
Windows bundling produces three signable artifacts, and the installers embed the app
binary:
1. target/release/FlyingCarpet.exe — the app binary
2. target/release/bundle/nsis/FlyingCarpet_<v>_x64-setup.exe — renamed to FlyingCarpet_<v>.exe
3. target/release/bundle/msi/FlyingCarpet_<v>_x64_en-US.msi
Signing only the installers leaves the installed FlyingCarpet.exe unsigned, which means
the firewall UAC prompt still reads "Unknown publisher" — i.e. the specific problem above goes
unfixed. Doing it properly needs two signing requests: tauri build --no-bundle, sign the
binary, drop it back into target/release/, then tauri bundle and sign the installers.
Start with the one-request version to prove the pipeline, then add the second.
Workflow skeleton
Only one secret to configure, SIGNPATH_API_TOKEN. Artifacts pass by ID, not by path.
name: Sign Windows Release
on:
push:
tags: ['v*']
jobs:
windows:
runs-on: windows-latest
permissions:
contents: write # attach assets to the release
actions: read # SignPath reads the workflow run
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install tauri-cli --version "^2" --locked
- run: cargo tauri build
working-directory: "Flying Carpet"
- name: Stage artifacts
shell: bash
run: |
mkdir -p dist
cp target/release/bundle/nsis/*.exe dist/
cp target/release/bundle/msi/*.msi dist/
- id: unsigned
uses: actions/upload-artifact@v4
with: { name: unsigned-windows, path: dist/ }
- uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORG_ID }}
project-slug: flyingcarpet
signing-policy-slug: release-signing
artifact-configuration-slug: windows-installers
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed/
- uses: softprops/action-gh-release@v2
with: { files: signed/* }Two things that will look like bugs and aren't
- The first runs appear to hang. Foundation release-signing policies typically require a
human approver in the SignPath UI, and wait-for-completion: true blocks until that
happens.
- Asset names must be renamed. Tauri emits FlyingCarpet_<v>_x64-setup.exe, but the
published asset is FlyingCarpet_<v>.exe. Without a rename step the download links and
release history go inconsistent.
Scope
Windows only. Android already signs with the release keystore — its certificate
(b6b7891c…c211405, CN=Theron Spiegl) was verified on 2026-07-27 to still match F-Droid'sAllowedAPKSigningKeys, so developer verification did not disturb it. Apple has its own
notarization path, and the .deb/.AppImage are unaffected.
Separately noted while checking that: the release APK is signed with v2 only (no v3), so
there is no PROOF_OF_ROTATION path — that keystore is permanent and unrecoverable-critical
for both Play and F-Droid. Enabling v3 on a future release is a one-line signingConfig
change but does not retroactively help published versions.
References: docs.signpath.io/trusted-build-systems/github, docs.signpath.io/origin-verification,signpath.org/terms.html.
---
7. Android asks for location on every version, including those that don't need it
Found during 10.0.3 device testing, 2026-08-05, after neverForLocation landed onBLUETOOTH_SCAN (commit efb1927). That flag removed the last reason API 31+ needs location
for Bluetooth, but nothing downstream was updated to match.
Where it stands
AndroidManifest.xml declares ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION with nomaxSdkVersion, and both branches of permissions (MainActivity.kt:725) include fine
location, so every launch on every supported version asks for it as part of the Bluetooth
bundle. checkForBluetoothPermissions() (:746) then treats it as mandatory for Bluetooth to
work at all, which is no longer true above API 30.
Target
Location requested only where it is genuinely required:
| API | Needed for | Source |
|---|---|---|
| 29–30 | BLE scanning and hosting a hotspot | neverForLocation doesn't exist below 31 |
| 31–32 | Hosting only | startLocalOnlyHotspot(); NEARBY_WIFI_DEVICES arrives at 33 |
| 33+ | Nothing | — |
Changes
1. Add android:maxSdkVersion="32" to ACCESS_FINE_LOCATION in the manifest, and the same to
ACCESS_COARSE_LOCATION. Keep coarse rather than deleting it — see the risk below.
2. Drop ACCESS_FINE_LOCATION from the SDK_INT >= S branch of permissions
(MainActivity.kt:728), leaving the pre-31 branch untouched.
3. Delete the commented-out ACCESS_COARSE_LOCATION at MainActivity.kt:727. Dead since it
was commented out.
4. Leave startHotspot() alone. MainViewModel.kt:628 already picks fine location below 33 and
NEARBY_WIFI_DEVICES at 33+, and requests it on demand, so it becomes the sole requester on
31–32 and hosting keeps working. The prompt moves from launch to the first hotspot, which is
the visible behavior change.
An earlier reading of this called for a three-way split of the request array. It doesn't: the
on-demand request in startHotspot() already covers 31–32.
The risk to design around
Android 12 requires that a fine-location request also declare and request coarse location in the
same call, or the dialog is refused. After change 2, the only fine-location request on 31–32 isstartHotspot()'s single-permission launch(requiredPermission) (MainViewModel.kt:637).
That may already be non-conforming today and working anyway, because the permission is normally
already granted from the launch bundle, which masks it. Verify on an emulator before deciding
whether to widen the call to request both together. Hosting working on a current device is not
evidence either way.
Testing
An Android 12 emulator (API 31 or 32) is the one that matters, for the request path and the
coarse/fine pairing above. The Samsung A03s on 13 confirms no location prompt appears at all and
that hosting still works through NEARBY_WIFI_DEVICES. An API 29 or 30 emulator confirms the old
path is untouched. Emulators can't host a real hotspot, so startLocalOnlyHotspot() succeeding
stays a hardware check.
README.md:74 describes the reasons rather than the versions, so it stays accurate either way,
but revisit it once behavior matches.
---
8. Bluetooth off at launch is unrecoverable without restarting
Found the same day, while removing a capability check that turned out to be unnecessary.
Bluetooth merely being switched off reaches the same failure branch as a device with no BLE
support, because openGattServer() returns null either way. Commit 833a0a4 made the message
name which one it is, but not the recovery: that branch calls setBluetoothSwitchEnabled(false),
so the switch listener at MainActivity.kt:808 that would retry initializeBluetooth() can't be
tapped, and onResume() (:843) only retries when permissions were the problem. Turning
Bluetooth on has no path back, which is why the message currently says to restart the app.
Two fixes, covering different flows
Leave the switch enabled when the failure is the adapter being off rather than absent hardware —
the distinction the message already draws. This mirrors what #101 did for permission denials and
makes tapping the switch the recovery. It covers turning Bluetooth on from the quick-settings
shade, which doesn't stop the activity and so never fires onResume().
Retry in onResume() when the adapter is now enabled, alongside the existing permissions check.
This covers leaving the app to change the setting and coming back. Needs a flag besidebluetoothPermissionsMissing recording that initialization failed on adapter state, so a
genuinely incapable device isn't retried on every resume.
Then drop "restart Flying Carpet" from the message in favor of tapping the switch.
Alternative
A BluetoothAdapter.ACTION_STATE_CHANGED receiver handles both flows automatically. The app
already registers one receiver for bond state (:824), so it isn't foreign, but it's more
lifecycle to get right. Reach for it only if the flag approach gets awkward.
Testing
Device-only and quick. Launch with Bluetooth off, confirm the message, turn Bluetooth on from the
shade, tap the switch, confirm a transfer works. Repeat by backgrounding the app instead of
tapping.
---
Send Folder Behavior
"Send Folder" behavior across the five platforms
Audited and fixed 2026-07-24, branch shared-network (both repos).
The rule, as of now
Selecting a folder recreates that folder inside the destination the receiver chose, with
its contents inside. Selecting individual files puts those files loose in the
destination. All five platforms behave this way.
Implemented uniformly as: every top-level selection is named relative to its own parent
directory. That single rule produces both behaviors — a selected folder's own name becomes
the first path component, while a selected file's parent is stripped down to the bare
filename — and it degrades sensibly for mixed and multi-directory selections.
The wire format is unchanged: one relative filename string per file, /-separated, as
before. This was never a protocol question — receivers just mkdir -p whatever parent
components arrive. It was entirely a sender-side choice of which prefix to strip.
What it used to be
macOS was the only platform that created the folder; the other four dumped the contents.
| Platform | Was | Now |
|---|---|---|
| macOS | creates the folder | unchanged — this was the model |
| iOS | dumps contents | creates the folder |
| Android | dumps contents (and broke against desktop, below) | creates the folder |
| Windows | dumps contents | creates the folder |
| Linux | dumps contents | creates the folder |
Where each platform implements it
macOS — Apple/shared/Transfer.swift, handleFileSelection:sendDir = urls[0].deletingLastPathComponent(), with sendFolder = true hardcoded. Unchanged.
iOS — same function, iOS branch. Was sendDir = urls[0] (the folder itself); nowurls[0].deletingLastPathComponent(), matching macOS. Both platforms share the prefix-strip
in Send.swift:112-119, so they now agree.
Android — Utilities.kt getFilesInDir(dir, pathSoFar), seeded from MainActivity.kt.
Was seeded ""; now seeded with dir.name.
Windows / Linux — core/src/utils.rs expand_selection(), called from the Tauriexpand_files command (Flying Carpet/src-tauri/src/main.rs). Each selected root is
expanded and its files named against the root's parent. The result — Vec<SendFile>,{path, name} — flows through Mode::Send to sending::send_file, which now just writes
the name it was given.
Moving the naming decision to selection time is what made the desktop side correct: the old
code tried to recover a prefix at send time from a flat list of absolute paths, by which
point the information about what the user actually picked was gone.
Bugs fixed along the way
1. Android → Windows/Linux failed outright for folders with sub-folders
getFilesInDir built nested paths as pathSoFar + '/' + name from a seed of "", so the
first level produced "/sub" and Android sent /sub/a.jpg — with a leading slash.
Receivers disagreed about that slash. Android (sanitizeRelativeFilename) and Apple
(safeDestinationURL) both skip empty components and coped. The Rust receiver
(receiving.rs sanitize_relative_filename) sees Component::RootDir and errors withReceived invalid filename path: /sub/a.jpg, aborting the transfer.
Worth recording that the hardening in 5debeda did not cause this — it exposed it. Before
that commit the Rust receiver did full_path.push("/sub/a.jpg"), and pushing an absolute
path replaces the buffer, so those files were written outside the chosen destination
entirely. Turning a silent path escape into a loud error was correct; the defect was always
on the Android side.
Fixed by seeding with the folder name (which makes pathSoFar non-empty) and by guarding
the join so it can never emit a leading separator regardless of seed. Covered bynames_are_relative_and_slash_separated, which asserts the Rust receiver's own sanitizer
accepts everything the Rust sender produces.
2. Desktop lost a directory level, or aborted, depending on the folder's shape
The old prefix was "the parent with the fewest components", ties broken by walk order:
- P with ≥1 file directly inside → prefix P. Contents dumped (the old intended behavior).
- P containing only P/s1 → prefix became P/s1, silently dropping s1 from every path.
- P containing only P/s1 and P/s2 → prefix locked to whichever was walked first, and
every file under the sibling failed strip_prefix, killing the transfer with
Error sending file: Strip prefix error.
The same abort hit drag-and-drop of two folders at once, or of files from two different
directories — which the deleted comment in lib.rs had half-acknowledged. Resolving each
selection against its own parent removes the shared-prefix concept entirely, so all of these
now work. Covered by folder_of_only_subdirectories_keeps_full_structure andselections_from_different_directories_coexist.
3. Empty file list panicked the desktop sender
start_transfer indexed files[0] to seed the prefix. JavaScript's if (!selectedFiles)
check passes an empty array through ([] is truthy), so an empty selection reached the core
and panicked the transfer task. Mode::Send now rejects an empty list with a message, and
the frontend reports empty selections before starting.
4. Short write of the file hash
Unrelated to folders, found by clippy while working here: sending.rs used stream.write()
for the 32-byte "do you already have this file" hash. A partial write would have sent a
truncated hash and left the remainder to be read as the next protocol field. Now write_all.
5. Help text described a gesture that mostly doesn't exist
"(To send a folder, drag it onto the window instead of clicking 'Start Transfer'.)"
Copy-pasted into the desktop app, Android, and macOS. Android has no drag-and-drop (it has a
Send Folder checkbox), and macOS has no drop handler either — its NSOpenPanel setscanChooseDirectories = true, so you just pick the folder. Each platform's text now
describes what that platform actually supports, and all four (plus iOS's storyboard help)
state that a sent folder is recreated on the receiving end.
Tests
core/src/utils.rs selection_tests — six cases over a real temp tree, run by cargo test:
| Test | Guards |
|---|---|
| selected_folder_keeps_its_own_name | the headline behavior |
| selected_files_are_flat | ordinary file sends didn't regress into folders |
| folder_of_only_subdirectories_keeps_full_structure | bug 2 |
| selections_from_different_directories_coexist | bug 2's abort cases |
| names_are_relative_and_slash_separated | bug 1, asserted against the real receiver sanitizer |
| nonexistent_selection_is_skipped | unreadable paths don't abort a whole transfer |
transfer_tests::end_to_end_encrypted_transfer now sends under album/photo.bin and asserts
the file arrives at recv/album/photo.bin, so directory recreation is covered end to end
through the real Noise stack.
Kotlin and Swift are covered by the Tier 6 hardware rows in docs/v10-release-test-plan.md —
neither has a unit-test seam over its platform file APIs (SAF DocumentFile,NSFileCoordinator).
Cross-repo note
The iOS/macOS half of this lives in Apple/ (shared/Transfer.swift,macOS/…/AppDelegate.swift, iOS/…/Main.storyboard) and must land with the changes here.
No wire-format bytes changed, so this is not a protocol break and needs no version bump —
but shipping one repo without the other would restore exactly the user-visible inconsistency
this removes.
---
Shared Network Crypto
Shared Network Mode: Cryptographic Design
Status: Implemented on all three platforms (Rust core, Android/Kotlin, Apple/Swift),
each verified byte-for-byte against the official Noise (cacophony) vector and shared app
KATs, including the preamble→prologue binding and tamper-negative tests (§11 post-review
hardening). Windows↔Android confirmed on real hardware pre-binding; first post-binding
live transfer re-confirms. Remaining: the rest of the live cross-platform matrix (§11
Phase 4), and porting the PSK-derived discovery HMAC key (§7, §9) to the Apple repo —
Rust and Android already have it. Audience: an engineer working across the Rust core, the
Swift (iOS/macOS) app, and the Kotlin (Android) app.
This document explains why the design is shaped the way it is, not just what to build,
so that the person writing the code understands which properties are load-bearing and
which lines they must not "simplify."
---
1. Why we're changing anything
Today, shared network mode derives its key as key = SHA256(password) and encrypts only
file contents with AES-GCM. This was acceptable when the only mode was hotspot, because
the two devices formed their own WPA2 network with no third party in the path. Over a
shared network (a café AP, an office LAN, an operator you don't control) the in-path
attacker is the expected case, and the current design fails against it in three ways:
1. Offline password cracking. SHA256(password) is a single fast hash with no salt
and no work factor. An eavesdropper who records the TCP stream tries each candidate
password — SHA256(guess) → trial-decrypt one chunk → the AES-GCM tag says
"right/wrong" instantly. The password space is ~2⁴⁸ (8 chars from a 55-symbol set),
which a GPU clears in minutes-to-hours.
2. No forward secrecy. The key is a pure function of the password, so every transfer
that ever used that password shares one key, and recovering the password (point 1)
retroactively decrypts all of them.
3. Cleartext metadata. Only chunk payloads are encrypted. File count, every
filename and its length, every file size, every chunk length, and the SHA-256 file
hashes are sent in the clear. A passive observer reads your directory structure and
file sizes with no cracking at all.
The goal of this design is to fix all three using only primitives available in Apple's
CryptoKit (a hard constraint: no third-party crypto on Apple platforms), which rules out
both Argon2id (no memory-hard KDF in CryptoKit) and any PAKE like SPAKE2 (none in
CryptoKit). What we can build from CryptoKit — X25519, HKDF, AES-GCM/ChaChaPoly, HMAC —
is a password-authenticated ephemeral key exchange. It does not reach full PAKE
security, and §7 is explicit about the one gap that leaves.
---
2. Threat model
Assets we protect: file contents, filenames, file sizes, file count, hashes — i.e.
everything about what is being transferred.
Attacker A — passive eavesdropper. Sees every byte on the wire (same Wi-Fi, span
port, malicious operator) but does not inject or modify traffic. This is the common,
realistic threat on an untrusted network. We defeat this attacker: they read nothing,
and their only recourse is a PBKDF2-hardened offline password crack (§7) that cannot
plausibly finish within the single-use password's lifetime — and that, thanks to forward
secrecy, decrypts nothing even if it someday does.
Attacker B — active in-path attacker. Can inject, modify, and MITM the TCP connection,
e.g. via ARP spoofing on the LAN, and can impersonate the peer's IP to win the connection.
We detect this attacker (they cannot silently sit in the middle and read plaintext),
but completing one handshake with a victim hands them an offline dictionary attack
on the password (§7). A real PAKE would deny even that; CryptoKit can't give us one.
Explicitly out of scope: traffic volume and timing. The length prefix of each
encrypted record is in the clear (you must know how many bytes to read), so an observer
still sees the approximate total transferred and the record cadence. Hiding that needs
padding/cover traffic, which we do not do. The plaintext preamble (protocol version
and send/receive direction) that precedes the handshake is visible to an eavesdropper —
these values are not secret — but it is not tamperable in any useful way: every preamble
byte is bound into the Noise prologue, so modifying it in flight fails the handshake
(§7).
---
3. The building blocks (plain-language)
X25519 (Elliptic-Curve Diffie–Hellman). Each side makes a random ephemeral keypair:
a secret scalar and a public point (A = a·G). They swap public points over the open
channel. Each combines its own secret with the other's public to get the same 32-byte
shared secret: a·B = b·A = ab·G. The property we rely on: a passive observer who sees
only A and B cannot compute ab·G — that's the Computational Diffie–Hellman
assumption, the same one all of TLS rests on. "Ephemeral" = the keypair is generated fresh
per transfer and thrown away after, which is what gives us forward secrecy.
HKDF (HMAC-based Key Derivation Function). Turns raw secret material (like the ECDH
output) plus context into one or more uniformly-random keys. Two steps: extract
(salt, input) → a pseudorandom key, then expand (that key, info label) → output
key(s). We use the info labels for domain separation: deriving several independent
keys from one exchange by expanding with different labels. HKDF is not a password
hardener — it has no work factor — so it is never used alone on the password.
AEAD (AES-256-GCM or ChaCha20-Poly1305). Symmetric encryption that provides
confidentiality and integrity: each encryption produces ciphertext plus a 16-byte
authentication tag; decryption fails loudly if any bit was altered. Each encryption takes
a nonce that must never repeat under the same key — nonce reuse in GCM is
catastrophic (it leaks the XOR of plaintexts and can forge tags). Nonce management is the
single most dangerous part of this design to get wrong; see §6.
HMAC. A keyed integrity tag. We use it for key confirmation: proving each side
derived the same session key (which simultaneously proves each side knew the password).
All five are in CryptoKit (Curve25519.KeyAgreement, HKDF, AES.GCM / ChaChaPoly,HMAC) and in RustCrypto (x25519-dalek, hkdf, aes-gcm/chacha20poly1305, hmac).
On Android they come from JCA (KeyAgreement "XDH" for X25519, API 30+; Mac "HmacSHA256";Cipher "AES/GCM/NoPadding") plus BouncyCastle for HKDF (or hand-rolled from HMAC) —
not Google Tink: Tink deliberately hides raw ECDH (its Java X25519 lives in thesubtle/@Alpha namespace marked "do not use in production, may be removed at any time,"
and Tink Java has no production X25519 key-agreement key type). Tink is a high-level
misuse-resistant library (AEAD, HPKE, MAC, signatures) and is the wrong abstraction level
for hand-assembling a handshake. In practice you would not source these individually on
Android at all — see §8.
---
4. The handshake, step by step
Roles are already fixed by the existing protocol: the receiver is the TCP server, the
sender is the TCP client. They already share the password (receiver displays it,
sender types it) and already have a TCP connection from discovery. The handshake is the
first thing that runs on that connection, before any file metadata is exchanged.
Sender (TCP client) Receiver (TCP server)
| |
gen ephemeral (a, A=a·G) gen ephemeral (b, B=b·G)
| |
|---------------- A (32 bytes) ------------->|
|<--------------- B (32 bytes) --------------|
| |
dh = X25519(a, B) dh = X25519(b, A)
| (both now hold ab·G) |
| |
derive keys = HKDF( ikm = dh || pw, (identical derivation)
salt = transcript,
labels... )
| |
|------ confirm_sender = HMAC(kc_s, T) ----->| verify; abort if bad
|<----- confirm_recv = HMAC(kc_r, T) ------|
verify; abort if bad |
| |
|=========== encrypted record layer =========|
| all file metadata + all file data, |
| AES-GCM under directional keys |Where:
- pw is the password material (see §5 on whether to PBKDF2 it first).
- transcript = A || B (the two 32-byte public keys in a fixed order — client's then
server's). Binding the transcript into the derivation ties the derived keys to *this
specific exchange*, which is what stops an attacker from replaying or reflecting one
side's messages.
- T is a confirmation transcript, e.g. "FC-v10-confirm" || A || B.
Why the cleartext public-key swap is safe. A and B go over the wire before any key
exists, so an active attacker (B) can swap in their own public key — the classic
unauthenticated-DH MITM. That is exactly what the password-bound key confirmation
catches: if the attacker substitutes their key, the victim derives its session key from(attacker's DH) || password, and the attacker would need the password to produce a
matching HMAC confirmation. They don't have it, so confirmation fails and the transfer
aborts. The password is what turns an otherwise-unauthenticated DH into an authenticated
one.
---
5. Key derivation — exact recipe
Do not invent the mixing yourself beyond this recipe. Better still, see §8: model the
whole handshake on the Noise Protocol Framework's PSK pattern, which specifies all of the
below in a reviewed way and is buildable from these same primitives.
ikm = dh (32 bytes) || pw_material # dh MUST be included -> passive immunity
salt = transcript = A || B # 64 bytes, same on both sides
prk = HKDF-Extract(salt, ikm)Four independent keys, one HKDF-Expand each, distinct info labels:
k_s2c = HKDF-Expand(prk, "FC-v10 sender->receiver data", 32)
k_r2s = HKDF-Expand(prk, "FC-v10 receiver->sender data", 32)
kc_sender = HKDF-Expand(prk, "FC-v10 sender confirm", 32)
kc_recv = HKDF-Expand(prk, "FC-v10 receiver confirm", 32)- Hash: SHA-256 throughout (HKDF-SHA256, HMAC-SHA256).
- AEAD: pick one and hard-code it for interop. AES-256-GCM is the safe default
(hardware-accelerated everywhere, in CryptoKit as AES.GCM). ChaCha20-Poly1305 is an
equally fine alternative; do not make it negotiable in v1.
- pw_material: at minimum the raw UTF-8 password bytes. Recommended:
pw_material = PBKDF2-HMAC-SHA256(password, salt = transcript, iterations = 600_000).
PBKDF2 is available on Apple via CommonCrypto (a system library) and in RustCrypto / JCA.
Note carefully what this buys: in this §4–§6 construction, the passive attacker
has no oracle regardless of KDF (the first password-dependent value on the wire is keyed
under dh, which they can't compute), so PBKDF2 only slows the active attacker's
offline crack. The implemented NNpsk0 design does not share that property — its
first handshake message is checkable by a passive observer (§7), which makes PBKDF2 the
primary defense there, not defense-in-depth. Using the transcript as the PBKDF2 salt is fine —
a salt need not be secret, only unique per session, which the ephemeral transcript
guarantees (and it prevents any precomputation).
Key confirmation. Sender sends HMAC(kc_sender, "FC-v10 confirm" || A || B); receiver
recomputes and compares in constant time, aborting on mismatch, then sendsHMAC(kc_recv, ...) which the sender likewise verifies. A mismatch means wrong password
or an active MITM — the user-facing message should say "could not establish a secure
connection; check the password and that you trust this network," not "wrong password"
(the two are indistinguishable here, and that's fine).
---
6. The record layer (this is where metadata gets encrypted)
Wire-order note (implemented design). The version and send/receive mode are
negotiated in a plaintext preamble on the raw TCP socket before the Noise
handshake; everything after the handshake — file count, all metadata, and all file data
— is inside Noise. This holds for both hotspot and shared network mode: they are
identical past the preamble. Every preamble byte, sent and received, is recorded and
bound into the Noise prologue (§9), so the preamble is readable in the clear but not
silently modifiable: tampering makes the two sides' prologues differ, which fails the
handshake. See §10 for why the preamble is plaintext and §7 for the security discussion.
The important architectural point: we do not rewrite the send/receive protocol. The
existing logic — send file count, send filename length, send filename, send size, send
chunks — runs unchanged, but writes into an encrypting wrapper instead of the raw
socket. The chunks are sent as raw bytes (no application-level cipher); Noise is the
sole encryption layer. Every logical message becomes one AEAD record:
on the wire: [ 2-byte big-endian length ] [ ciphertext || 16-byte AEAD tag ]Implementation note: the shipped Rust reference builds this record layer from Noise's
transport messages (§8), so the "directional keys" and "counter nonces" below are
managed by Noise/snow internally rather than derived by hand, and the length prefix is2 bytes (u16) because Noise caps a message at 65535 bytes. §4–§6 are the conceptualmodel; §9 lists the exact framing and test vectors the implementation actually uses.
- Directional keys. Sender→receiver records use k_s2c; receiver→sender records use
k_r2s. Two keys means the two nonce counters live in separate spaces and can never
collide — this is the clean way to avoid nonce reuse between the two directions.
- Counter nonces, not transmitted. Each direction keeps a 96-bit counter starting at
0, incremented by exactly one per record sent. The nonce is the counter, big-endian; it
is not put on the wire (both sides track it deterministically, like TLS 1.3 sequence
numbers). Never reset a counter mid-connection. Never reuse a key across connections
(the ephemeral handshake guarantees fresh keys each time).
- GCM data limit. Stay well under ~2³⁹ bytes (~64 GB) encrypted under a single
directional key before the GCM security margin degrades. Flying Carpet transfers are far
below this; if you ever expect larger, that's the point to add rekeying, but do not add
it speculatively.
- The plaintext inside each record is exactly what the current protocol writes today:
the u64 file count, the u64 filename length + filename bytes, the u64 file size, and the
file chunks. All of it is now confidential and tamper-evident. The header-value bounds
we already added (file count / filename length / chunk size sanity checks) still apply
to the decrypted values.
---
7. What this achieves — and why the residual gap barely matters here
| Property | Status |
|---|---|
| Confidentiality of file contents | ✅ AEAD under session key |
| Confidentiality of metadata (names, sizes, count, hashes) | ✅ record layer |
| Integrity / tamper-evidence | ✅ AEAD tags + key confirmation |
| Forward secrecy (past transfers safe if password later leaks) | ✅ ephemeral X25519 |
| Offline password crack by passive eavesdropper (Attacker A), from the Noise channel | ⚠️ PBKDF2-hardened — the first handshake message's AEAD tag is an offline oracle (see below) |
| Offline password crack by passive eavesdropper, from the discovery announcement | ⚠️ PBKDF2-hardened (same 600k-iteration cost) — see note below |
| Active MITM goes undetected | ✅ prevented (key confirmation) |
| Offline password crack by active attacker (Attacker B) | ⚠️ possible at the same PBKDF2 cost, and yields nothing of value — see below |
Discovery is keyed from the stretched PSK (shared network only). The Noise channel
rows above are not the whole wire: the UDP discovery announcement is also HMAC-signed with
a password-derived key. It used to be HMAC(SHA256(password), …) — a fast hash, which
gave a passive eavesdropper who captured a single announcement an offline dictionary
attack cheap enough (~hours on one GPU, minutes on a rig, over the ~2⁴⁸ space) to
plausibly finish while the password was still live (receiver waiting, or mid-transfer
of something large). A live recovered password defeats everything: the attacker knows the
PSK and can run a fully valid MITM that passes key confirmation. This was the one scenario
that broke the "effectively as strong as a PAKE" argument below. Fixed: the discovery HMAC
key is now derive_discovery_key(psk) = HMAC-SHA256(psk, "Flying Carpet v10 discovery")
(§9), where psk is the PBKDF2-stretched key — so a captured announcement costs an
offline attacker 600k PBKDF2 iterations per guess, identical to the handshake-message
oracle below: centuries per GPU, not hours. The label gives domain separation (the
Noise PSK itself is never used outside the handshake). No fast hash of the password goes
on the air; SHA256(password) survives only in the hotspot SSID's 2-byte tag, which is
not part of shared network mode.
Why not drop discovery authentication entirely? Considered and rejected. It would not
remove the passive oracle — NNpsk0 message 1 carries the same PSK-keyed tag in every
recorded transfer regardless (below); it would only shrink the live oracle window from
minutes (announcements start once the receiver has the password) to milliseconds (message
1 immediately precedes handshake completion). Nor does the HMAC gate online guessing: the
receiver's TCP port accepts direct connections from anyone, and Noise limits any connector
to one online guess either way. What the HMAC actually buys is peer selection:
the sender connects only to the machine that provably holds the password, so concurrent
transfers on one LAN can't cross-connect and mutually fail, and an in-LAN mischief-maker
can't answer discovery first to make every transfer die at the handshake. That reliability
is worth a PBKDF2-hardened oracle window measured in minutes against a crack measured in
GPU-years.
The residual gap: the wire carries PBKDF2-hardened password oracles. The §4–§6
conceptual design had the property that a passive observer gets no oracle at all — the
first password-dependent value there is keyed under dh, which they can't compute. The
implemented NNpsk0 pattern does not have that property. Per the Noise spec (§9.1),
in psk handshake patterns the e token additionally calls MixKey(e.public_key), so
the initiator's first message — 32-byte ephemeral plus a 16-byte AEAD tag over an empty
payload — is keyed by a function of (protocol name, prologue, PSK, e.pub): everything
public except the PSK. A passive eavesdropper who records message 1 can test passwords
offline: PBKDF2(guess) → run the key schedule → check the tag. The discovery announcement
gives the same oracle even earlier (above). And an active attacker (Attacker B) who
terminates one side's connection gets the equivalent oracle from the victim's handshake
message — this last one is inherent to any password-authenticated exchange that is not
a PAKE: someone must send a password-dependent message first, and its recipient gets an
offline oracle. Every one of these oracles costs the same — one 600k-iteration PBKDF2 per
guess, over a ~2⁵⁸ space (10 CSPRNG chars from a 57-symbol set): hundreds of thousands of
GPU-years. The length also forecloses the one shortcut the fixed PBKDF2 salt (§9) leaves
open in principle — a one-time precomputed table over the whole password space, reusable
against every transfer — which at 2⁵⁸ is infeasible in both compute and storage (~exabytes).
In a system with long-term or
reused passwords the oracle would still matter — crack once (however slowly),
impersonate forever — and a formally-proven PAKE (SPAKE2, CPace) is what removes it, by
limiting even an active attacker to one online guess per connection with nothing
crackable ever hitting the wire. A PAKE is precisely what the CryptoKit-only constraint
excludes.
Why it has no operational payoff in Flying Carpet. Two properties of this specific
design neutralize the gap:
1. The password is single-use and randomly generated. The receiver mints a fresh
CSPRNG password per transfer and displays it out-of-band; it is never reused. The
offline crack is, by construction, not real-time — PBKDF2 makes each guess cost ~600k
hashes, so recovering the password takes far longer than the transfer it belonged to.
By the time the attacker holds it, it authenticates nothing, decrypts nothing, and
predicts nothing (CSPRNG output doesn't leak future outputs). The "crack once,
impersonate later" payoff requires the reuse this design doesn't have.
2. A cracked password never decrypts recorded traffic. A recorded session does
contain the oracle — message 1's tag rides in every taped transfer — so an attacker
can, in principle, spend the GPU-centuries and recover the password of a transfer they
recorded. It buys nothing retroactive: the transport keys also depend on the ephemeral
ee DH secret, which no amount of password knowledge reveals (the same CDH wall the
passive attacker hits — this is forward secrecy doing its job). A recovered password is
only useful prospectively: impersonating an endpoint of, or MITMing, a handshake that
hasn't happened yet. So the crack would have to finish inside the password's live
window — from first discovery broadcast to handshake completion, seconds to minutes —
against a cost of years-to-centuries of GPU time. Outside that window, the single-use
password authenticates nothing, decrypts nothing, and predicts nothing.
Net. Against every attacker this design actually faces — passive eavesdropper, and
active LAN attacker who intercepts a live transfer — cracking the recovered password yields
nothing of value. The construction is therefore, *for Flying Carpet's single-use-password
model*, effectively as strong as a PAKE would be here. A PAKE would add cleaner theory and
defense-in-depth, but no practical protection against any realistic attacker — which
substantially weakens the case for taking on the SPAKE2 cross-language burden.
Load-bearing invariants. If either is ever broken, the reasoning above collapses and
the case for a PAKE returns:
- Passwords must remain single-use. No "remember this password," no user-chosen fixed
passwords, no reuse across transfers. Treat this as a security invariant of the mode, not
a UI convenience — the entire "the crack is worthless" argument rests on it.
- Passwords must come from a CSPRNG (rand::thread_rng / SecureRandom /
SecRandomCopyBytes), so recovering past passwords never helps predict future ones.
Caveats that remain regardless. An active attacker can still disrupt a transfer —
intercept-and-abort is a denial of service available to anyone in-path, unrelated to the
crypto and not fixable by it. The plaintext preamble (§6) is *bound into the Noise
prologue* (§9): an attacker who flips a bit in the version or mode exchange makes the two
sides compute different prologues, and the handshake fails — tampering is detected, never
silently accepted. For v10↔v10 alone this only converts one DoS into another (the preamble
carries no secret, and each side decided its own send/receive role locally; the exchange
only verifies they're opposite), but the binding is load-bearing for the future: when a
later version changes anything crypto-relevant, an in-path attacker must not be able to
rewrite the version exchange and silently pin both peers to v10 semantics. Prologue
binding only closes that downgrade window if it exists from the first Noise version —
which is why it ships in v10 rather than being retrofitted.
---
8. Strong recommendation: build it as a Noise pattern, don't free-hand it
The Noise Protocol Framework specifies handshakes that are exactly "ephemeral X25519 + HKDF
+ AEAD, with an optional pre-shared key mixed in," and it pins down the transcript hashing,
the key schedule, the nonce discipline, and the directional keys in a reviewed, widely-
implemented way. The NNpsk0 pattern (both sides ephemeral-only, a PSK folded in at the
start) is essentially §4–§6 of this document, formalized. Feed pw_material in as the
Noise PSK.
Why this matters: Noise's security profile with a low-entropy PSK is nearly the same as
our hand-rolled construction — not a PAKE, so the password is offline-crackable at PBKDF2
cost. The one difference: NNpsk0 exposes that oracle to a passive observer via the
first message's AEAD tag, where the §4–§6 construction keyed everything checkable underdh (§7) — a difference with no operational impact given the crack economics. In exchange
we gain a specified,
peer-reviewed recipe for the mechanics that are easy to get subtly wrong (transcript
binding, nonce counters, key separation).
Per-platform implementation plan
Because Noise is a spec, conforming implementations of the same protocol name interoperate.
Fix the protocol name up front — proposed: Noise_NNpsk0_25519_ChaChaPoly_SHA256
(ChaChaPoly because it is Noise's most universally-tested cipher and is in CryptoKit asChaChaPoly; AES-GCM is an equally valid pin if preferred). Then:
- Rust core → use snow. Mature, ~1.4M downloads, tracks Noise spec rev 34, actively
maintained (0.10.0 released mid-2026), supports the NNpsk0 pattern. Pure-Rust crypto by
default, no C deps. Caveat: no formal audit (but it is the de-facto Rust Noise library,
widely deployed). This is the reference implementation the other two match against.
- Android → hand-rolled on JCA + a vendored X25519 (see §11 Phase 2 for the full
rationale). The obvious libraries don't fit minSdk 29 / Java 8 / modern NNpsk0:
noise-java (rweather) implements the deprecated pre-2018 PSK scheme (incompatible with
snow — verified against the cacophony vector), and java-noise (jchambers) needs Java 17
and JCA X25519, which Android's platform only added in API 34. So Android hand-rolls
the symmetric state + handshake in Kotlin using JCA ChaCha20-Poly1305 (API 28+) and
HMAC/SHA-256, plus the one pure-Java file Curve25519.java vendored from noise-java for
X25519 (API 29 / Java 8 safe). Do not try to assemble it from Tink (§3).
- Apple → hand-implement NNpsk0 on CryptoKit. The "Apple standard crypto only"
constraint forbids a third-party Noise library, and there is no well-established CryptoKit
Noise library anyway. But Noise is small: the handshake needs only X25519
(Curve25519.KeyAgreement), HKDF (HKDF), the AEAD (ChaChaPoly), and SHA-256/HMAC —
all present. You implement the Noise symmetric state (the MixHash/MixKey/Split
key schedule and the PSK token) by hand, following the spec. This is the one hand-rolled
side and the main source of interop risk, which is exactly what the §9 test vectors guard.
The PSK fed into NNpsk0 is 32 bytes: psk = HKDF-or-PBKDF2(password) → 32 bytes. Use
PBKDF2 (§5) so the active-attacker offline crack is slowed; derive it identically on all
three sides (same iteration count, same salt). Everything else — nonce counters, directional
keys, transcript binding — is handled by the Noise pattern itself, which is the whole
reason to use it rather than the §4–§6 hand-rolled version. Treat §4–§6 as the conceptual
model; treat the Noise NNpsk0 spec as the normative reference for the bytes.
---
9. Interop and testing (the part that actually eats the time)
Because the handshake is standard Noise (§8), most byte-level details are fixed by the
Noise spec and the protocol name. What each platform must still pin identically:
- Protocol name: Noise_NNpsk0_25519_ChaChaPoly_SHA256 (exact string).
- Prologue: the preamble transcript, canonically framed. Each side records every byte
it sends and receives during the plaintext preamble (the whole version exchange,
including the 8-byte compatibility confirmation when versions differ, then the whole
mode exchange) and builds
prologue = u64_be(len(T_i)) || T_i || u64_be(len(T_r)) || T_r where T_i is every byte the Noise initiator sent and T_r every byte the
responder sent. The initiator computes this as (my sent, my received), the responder
as (my received, my sent); untampered, both get identical bytes. The length prefixes make
the encoding unambiguous (no boundary-shifting between the two transcripts). Implemented
as build_prologue / buildPrologue next to each Noise implementation, with the
transcript captured by a recording wrapper at the stream boundary so no branch of the
negotiation can leak a byte out of the transcript. Per the Noise spec the prologue enters
only h (it gates the handshake MACs, not the derived keys) — that is exactly the
desired property: mismatch ⇒ handshake abort.
- PSK derivation: PBKDF2-HMAC-SHA256(password_utf8, salt, iters) → 32 bytes, with
salt = b"Flying Carpet v10 shared network PSK" and iters = 600000.
- Discovery HMAC key: discovery_key = HMAC-SHA256(key = psk, data = b"Flying Carpet — derived from the stretched PSK (never from a fast hash of the
v10 discovery")
password; §7, §10), with a fixed label for domain separation so the Noise PSK itself is
never used outside the handshake. The PSK is derived once, when the password becomes
known and before discovery starts, and reused for the handshake. Implemented as
derive_discovery_key / deriveDiscoveryKey next to each PSK derivation.
- Record framing: each Noise message is prefixed by its length as a 2-byte
big-endian integer (u16), tag appended (not prepended). 2 bytes, not 4, because
Noise caps messages at 65535 bytes; the same framing is used for the two handshake
messages and every transport record.
- Roles: the Noise initiator is the TCP client, the responder is the TCP
server — for both modes. Shared network: sender = client = initiator, receiver =
server = responder. Hotspot: the guest that joined and connected = client = initiator,
the host = server = responder.
Spec conformance is verified against the official vectors. core/src/noise.rs testofficial_noise_test_vector drives snow with the canonicalNoise_NNpsk0_25519_ChaChaPoly_SHA256 test vector from
haskell-cryptography/cacophony
(vectors/cacophony.txt) — its prologue, PSK, and fixed ephemerals — and
asserts the handshake message ciphertexts, the handshake hash
(f4d03dc3…be208eaf), and the first transport record match byte-for-byte. This is what
guarantees the Swift/Kotlin ports (which follow the same spec) interoperate; each port
should run the same cacophony vector against its own Noise implementation. The
app-specific KAT vectors below (PBKDF2-derived PSK, app-style prologue) are a separate
determinism check for our exact parameters, not a spec check.
Cross-platform known-answer test vectors. These are emitted and asserted by the Rust
reference (core/src/noise.rs, tests psk_known_answer, handshake_known_answer, andprologue_known_answer); Swift and Kotlin must reproduce them exactly. snow'sfixed_ephemeral_key_for_testing_only fixes the ephemerals so the whole handshake is
deterministic.
- PSK for password "flyingcarpet":
a3d8b7f17f2252e4c2847a365ab2f392beaa996b7e51dd6fa19ff1ad08938619
- Discovery HMAC key for that PSK:
45e49b632788b21069bf48720d6af230ecbd936b3cb16c898a8e1eac51944112
With fixed PSK = 2a×32, initiator ephemeral private = 01×32, responder ephemeral
private = 02×32, empty prologue:
- Handshake msg 1 (initiator → responder), 48 bytes:
a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209a3e9c18456aba2185de800ffaca55b22
- Handshake msg 2 (responder → initiator), 48 bytes:
ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59d887595caf8a0b110dfab84e6b41eafc
- First transport record from the initiator, plaintext "hello flying carpet":
124a00c03b4544f746828bbf9ae2d8d595a9ac1fea988f43f7206c3880180b954f9147
Prologue-bound KAT (same PSK/ephemerals, with the app-style preamble transcriptT_i = 000000000000000a 0000000000000001 (version 10, mode send) andT_r = 000000000000000a 0000000000000000 (version 10, mode receive)):
- build_prologue(T_i, T_r), 48 bytes:
0000000000000010 000000000000000a0000000000000001 0000000000000010 000000000000000a0000000000000000
- Handshake msg 1: a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a2093ae03dc8524f79ac9696d6c155df9a3c
- Handshake msg 2: ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59d2668070263116ce557500fbe3fd3ba4
- First transport record ("hello flying carpet"):
124a00c03b4544f746828bbf9ae2d8d595a9ac1fea988f43f7206c3880180b954f9147 — identical to
the empty-prologue record by design: the prologue enters only h, never the chaining
key, so it changes the handshake message MACs (see msg 1/2 differing after the 32-byte
ephemeral) but not the transport keys. Asserted anyway so a port that wrongly mixes the
prologue into ck fails the KAT.
(The transport-record vectors are raw Noise messages; on the wire each is preceded by its
2-byte length prefix 0023.) These are the vectors that guarantee a macOS sender can talk
to an Android receiver; each platform asserts them in its unit tests (Rustcore/src/noise.rs, Kotlin NoiseUnitTest, Swift NoiseTests) since they catch label and
endianness mismatches that otherwise surface as "handshake fails, no idea why." Each
platform also asserts the negative cases: a tampered transport record, a tampered
handshake message, a mismatched password, and a mismatched prologue must all fail.
---
10. Migration, scope, and locked decisions
Decisions locked (from design review):
- Cipher: ChaCha20-Poly1305. Noise protocol name Noise_NNpsk0_25519_ChaChaPoly_SHA256.
(AES-GCM is also in CryptoKit and would be equally valid; ChaChaPoly chosen as Noise's
most-tested cipher.)
- Noise over BOTH modes. Hotspot mode also runs the Noise handshake now (password =
the hotspot password, known to both sides), so there is one encryption path for all
transfers. WPA2 still wraps the hotspot link, but the app no longer relies on it for
confidentiality.
- No inner AES. The old per-chunk AES-256-GCM (keyed by SHA256(password)) is
removed — Noise is the sole encryption layer; chunks are raw bytes inside it. The
aes-gcm dependency is gone. SHA256(password) survives only for the SSID and the
discovery HMAC.
- Plaintext version/mode preamble, bound into the prologue. Version confirmation and
send/receive negotiation happen on the raw socket before the handshake (§6, §7), for
clean version-mismatch reporting — and the full transcript is bound into the Noise
prologue (§9), so the preamble is readable but not silently modifiable. The binding
ships in v10 (the first Noise version) because that is the only point at which it can
close the future downgrade window (§7).
- No v9 compatibility. v10 is a clean break; a v9 peer is rejected with a clear message.
- Discovery HMAC keyed from the stretched PSK (originally shipped as
HMAC(SHA256(password), announcement), fixed within v10 before release). The fast-hash
key made a single captured announcement a cheap offline oracle — crackable within the
password's live window by a resourced attacker, enabling a real MITM (§7). The
announcement is now signed with derive_discovery_key(psk) (§9), so every oracle
anywhere in the protocol costs 600k PBKDF2 iterations per guess. Wire-format unchanged
(same 93 bytes); old and new builds simply never discover each other, which is fine
within the unreleased v10. SHA256(password) now survives only for the hotspot SSID.
Discovery stays authenticated — dropping the HMAC (no oracle from announcements) was
considered and rejected: the handshake's message-1 oracle remains regardless, and the
HMAC is what gives correct peer selection on a shared LAN (§7). Random per-device names
as the selection mechanism, with the password as a separate secret, were likewise
rejected: unauthenticated names are spoofable, so they'd need either the same
password-derived MAC or a manual verify-the-name step.
Version bump — already implemented (the only code landed ahead of the Noise work):
- MAJOR_VERSION 9 → 10 in the Rust core (core/src/lib.rs), Android (MainViewModel.kt),
and Apple (Transfer.swift, VERSION).
- Compatibility floor raised to 10 (utils::is_compatible, the Kotlin peerVersion >= 10
check, and Swift isCompatible), so a v9 peer is incompatible.
- Version mismatch now produces a clear, user-facing message naming both versions and the
download page, on all three platforms.
A versioning subtlety to respect. The version number only protects the Noise break if
the Noise wire format never ships under the same number as a non-Noise build:
- The Noise change must land as part of v10 — do not release a v10 build before Noise
is in. If a v10 is ever released pre-Noise, the Noise wire change must bump to v11,
because two builds both reporting "10" with different record-layer formats would not
detect the mismatch; they'd fail cryptically instead.
- There is no interop between v10's encrypted handshake and v9's cleartext protocol, and
hotspot-vs-shared-network is chosen locally before any bytes flow, so v10 just runs its
protocol and a v9 peer fails the version exchange with the message above. No downgrade
path.
The logical send/receive protocol does not change — only the transport under it (and
the removal of the redundant per-chunk AES). The header-value bounds and filename
sanitization already added remain, applied to the values read from the Noise-decrypted
stream.
---
11. Implementation plan (phased)
Order chosen so the cheapest, most-verifiable piece comes first and becomes the reference
the others are tested against.
Phase 0 — done
Version bump + mismatch messaging (§10). The only pre-Noise code change.
Phase 1 — Rust core reference implementation (snow) — done
Landed in
core/src/noise.rs and wired into core/src/lib.rs:1.
snow and pbkdf2 added to core/Cargo.toml (and aes-gcm removed); protocolNoise_NNpsk0_25519_ChaChaPoly_SHA256.2.
derive_psk() = PBKDF2-HMAC-SHA256 with the fixed salt/iters constants (§9).3.
start_transfer runs the plaintext version/mode preamble on the raw TCP stream,then the Noise handshake, for both modes. Noise initiator = TCP client, responder =
TCP server (§9). A wrong password fails the handshake with a clear message ("Could not
establish a secure connection. Check that the password matches…").
4.
EncryptedStream<S> implements tokio AsyncRead + AsyncWrite, transparently splittingthe byte stream into ≤64 KiB Noise records with a 2-byte length prefix.
send_file /receive_file / confirm_version / confirm_mode are generic over the stream; aTransferStream enum represents the Plain→Encrypted transition (Plain during thepreamble, Encrypted after the handshake).
5. Inner AES removed:
send_file/receive_file send/receive raw chunks; Noise is thesole encryption layer.
SHA256(password) remains only for the SSID and discovery HMAC.6. Spec conformance verified against the official Noise vectors:
official_noise_test_vector drives snow with the canonical cacophony vector for theprotocol and asserts the message ciphertexts, handshake hash, and transport record
match byte-for-byte — this is the real cross-platform interop guarantee. Additional
app-parameter KAT vectors (
psk_known_answer, handshake_known_answer) are transcribedinto §9 as a determinism check.
7. Verified:
end_to_end_encrypted_transfer runs the real send/receive over an encryptedduplex with a 200 KB (multi-record) file;
wrong_password_fails_handshake,round_trip_small_and_large, and tampering_is_detected all pass. 19 core tests green.Phases 2 and 3 must mirror the full design: plaintext version/mode preamble → Noise
handshake (both modes) → raw chunks inside Noise (no per-chunk AES). The role rule is the
same everywhere: TCP client = initiator, TCP server = responder.
Phase 2 — Android (hand-rolled on JCA + vendored X25519) — done
Library dead-end (why hand-rolled). Neither obvious Java Noise library fits this app
(minSdk 29, Java 8, must speak modern NNpsk0):
- rweather/noise-java implements the deprecated pre-2018 PSK scheme (NoisePSK_
prefix, no psk token, SymmetricState has no mixKeyAndHash). Verified empirically:
it can't parse Noise_NNpsk0_… ("Handshake pattern is not recognized"), and its
NoisePSK_NN_… output diverges from the official cacophony NNpsk0 vector after the
ephemeral — cryptographically incompatible with the Rust/snow side.
- jchambers/java-noise does modern psk0 but is Java 17 source and calls JCA
KeyAgreement.getInstance("X25519"), which Android's platform (Conscrypt) only added in
Android 14 / API 34 — so it would force minSdk 34 (dropping everything below Android
14) or bundling BouncyCastle as a provider. Not acceptable.
So Android hand-rolls the NNpsk0 symmetric state + handshake + transport in Kotlin
(Noise.kt), the same shape as the Apple/CryptoKit side. Only the primitive that isn't in
API-29 platform crypto is vendored:
- X25519: the single pure-Java file com/southernstorm/noise/crypto/Curve25519.java
(from rweather/noise-java, MIT; only its crypto leaf, not its handshake layer). Works on
API 29 / Java 8.
- ChaCha20-Poly1305 (JCA, Cipher "ChaCha20-Poly1305", Android 9 / API 28+),
HMAC-SHA256 / SHA-256 (JCA). PSK = the same PBKDF2 as Rust, hand-computed over the
UTF-8 password so it can't hit PBEKeySpec char-encoding ambiguity.
Verified: NoiseUnitTest reproduces the official cacophony vector and the §9 app KATs
byte-for-byte, plus a multi-record stream round-trip and a wrong-password rejection — 5
tests, matching the Rust reference. Wiring mirrors Phase 1: plaintext version/mode preamble
(already on the raw socket) → noiseHandshake for both modes (client = initiator, server =
responder) → the socket streams are swapped for NoiseInputStream/NoiseOutputStream, and
the per-chunk AES is removed from Send.kt/Receive.kt (raw chunks). Live Android↔Windows
verification is still pending real devices, but the shared cacophony/app KATs are the
interop guarantee.
Phase 3 — Apple (hand-rolled on CryptoKit) — done
In the Swift port (
Apple/shared/Noise.swift), the same shape as the other two:1. Hand-rolled
NNpsk0 symmetric state + handshake using CryptoKit — X25519(
Curve25519.KeyAgreement), ChaCha20-Poly1305 (ChaChaPoly), SHA-256, HMAC — andCommonCrypto PBKDF2 for the PSK (UTF-8 password, identical salt/iters). No
third-party crypto, honoring the Apple-standard-only constraint.
2.
NoiseConnection conforms to the existing TCPConnectionProtocol (write /receiveNBytes), so after the handshake the transfer code runs unchanged over it; theper-chunk AES is removed from
Send.swift/Receive.swift (raw chunks).3. Wiring (
Transfer.sendAndReceive): plaintext version/mode preamble → noiseHandshakefor both modes → replace
self.tcp with the NoiseConnection. Role: shared-networksender = initiator / receiver = responder; hotspot = initiator (Apple always joins a
hotspot, never hosts).
4.
NoiseTests (macOS test target) reproduces the official cacophony vector and the §9app KATs byte-for-byte, plus a multi-record round-trip and a wrong-password rejection —
the same vectors Rust and Android assert. Compiles/runs on the developer's Mac (not
buildable on the Windows dev host);
shared/Noise.swift must be added to the iOS andmacOS app targets in Xcode (created outside the IDE).
Post-review hardening — done (all three platforms)
From the code review of Phases 1–3:
1. Preamble → prologue binding (§6, §7, §9): every preamble byte, sent and received,
is recorded by a stream-boundary wrapper (
RecordingStream /RecordingInputStream+RecordingOutputStream / RecordingTCPConnection) and boundinto the handshake via
build_prologue/buildPrologue. New cross-platformprologue_known_answer KAT plus a prologue-mismatch negative test on each platform.The handshake-failure message now mentions tampering as well as password mismatch.
Wire-breaking within the unreleased v10 — all three platforms landed together.
2. Real tamper tests: each platform now asserts that a bit-flipped transport record
and a bit-flipped handshake message fail authentication (the old Rust
tampering_is_detected only exercised the happy path).3. Rust: hotspot stored in state before the preamble (
start_transfer), soclean_up_transfer tears the Windows hotspot down even when the version check, modecheck, or handshake fails (previously those paths left it running until app exit).
Phase 4 — cross-matrix + cleanup (remaining)
All three platforms now implement the same modern
NNpsk0 and pass the shared cacophony +app KATs, so they interoperate by construction. Confirmed on real hardware so far:
Windows↔Android over both hotspot and shared network — before the prologue binding; the
first post-binding live transfer re-confirms it. Still to do: the rest of the live
matrix (add macOS / iOS × sender / receiver, and Linux), a per-file size larger than one
record end-to-end, and the wrong-password / version-mismatch user-facing paths. The old
cleartext per-chunk AES is already removed on all three platforms. The PSK-derived
discovery key (§9) is implemented in Rust and Android (with the shared KAT); the Apple
repo must mirror it —
deriveDiscoveryKey via CryptoKit HMAC<SHA256>, PSK derived onceat password time (off the main thread) and fed to both discovery and the handshake —
before any v10 release, since the key change is a silent discovery-compat break.
Open items — resolved
- PBKDF2 salt → fixed domain string
b"Flying Carpet v10 shared network PSK". TheNoise handshake hash already binds the ephemeral transcript, so the salt's only job is
domain separation and a fixed value is simplest to keep byte-identical across languages.
-
snow audit posture → accepted. snow has no formal audit (it says so), but itis the de-facto Rust Noise library and widely deployed; the fallback if that ever becomes
unacceptable is a hand-rolled Rust implementation matched to the Apple one.
---
12. Summary for the implementer
1. Build the handshake as Noise Noise_NNpsk0_25519_ChaChaPoly_SHA256 (§8), not the
§4–§6 hand-rolled mixing — treat §4–§6 as the conceptual model and the Noise spec as the
normative reference for the bytes.
2. The PSK is PBKDF2(password) → 32 bytes (§5), derived identically on all three platforms.
3. Wrap the socket so the existing send/receive logic runs unchanged over the Noise
transport (§6); the receiver is the responder, the sender the initiator.
4. Write the cross-language known-answer vector first (§9); it is what makes a macOS
sender talk to an Android receiver, and the guard for the hand-rolled Apple side.
5. Follow the phased order (§11): Rust snow reference → Android noise-java → Apple
CryptoKit hand-roll → full matrix.
6. Version is already bumped to a clean-break v10 with mismatch messaging (§10); ship Noise
within v10 (or bump to v11 if v10 releases first).
7. Know exactly what you're shipping (§7): eavesdroppers read nothing, but both passive and
active attackers hold a PBKDF2-hardened offline password oracle (discovery announcement
and first handshake message). It is neutralized by crack cost ≫ single-use password
lifetime plus forward secrecy, so this is effectively as strong as a PAKE would be *for
this design* — provided passwords stay single-use and CSPRNG-generated.
---
V10 Release Plan
Flying Carpet v10 — Release Plan
Companion to docs/v10-release-test-plan.md (what to test) anddocs/post-v10-maintenance.md (what's deliberately deferred). This doc holds the draft
release notes and the ship checklist.
Branch: shared-network, both repos. 74 commits, ~11,900 insertions over main.
Versions already bumped: Rust core 10.0.0, Tauri app 10.0.0, Android versionName 10.0.0
/ versionCode 22. Apple repo versions still need checking (see checklist).
---
Draft release notes
Flying Carpet 10.0
Flying Carpet 10 adds Shared Network mode and rebuilds the encryption on the Noise
Protocol Framework.
⚠️ Version 10 is a breaking change. v10 devices cannot transfer with v9 or earlier —
you'll get a clear version-mismatch message instead of a hang. Update every device you
transfer between.
Shared Network mode
Until now Flying Carpet always created its own ad-hoc WiFi hotspot. You can now transfer over
a WiFi or wired network that both devices are already on — useful at home or in an office,
and much faster to start when there's a network handy.
- Discovery is authenticated: devices announce themselves on port 3290 with an HMAC keyed
from the transfer password, so you only ever see peers that hold your password.
- The receiver generates a single-use password and displays it (with a QR code); the sender
types or scans it. No Bluetooth involved, and the two devices' operating systems don't
matter — no need to select the peer's OS.
- This is what finally makes Apple-to-Apple transfers work. iPhone↔Mac transfers were
impossible in hotspot mode because neither device can host for the other. Join both to the
same network — including a hotspot you made manually beforehand — and it just works. This
also unblocks Android↔iOS, which had been broken by the iOS WiFi path (#131).
- Wired connections are supported (#124). A desktop on ethernet can transfer with a phone
on WiFi, as long as they're on the same network — and a machine with no WiFi card at all
can now use Flying Carpet for the first time (#93).
- Interface picker: a labeled dropdown showing each interface's IP, with unusable ones
hidden, for machines with several NICs.
- No hotspot means no ad-hoc WiFi connection to drop partway through a large transfer (#130).
Rebuilt encryption
Every transfer, in both modes, now runs aNoise_NNpsk0_25519_ChaChaPoly_SHA256 handshake (X25519 + ChaCha20-Poly1305 + SHA-256), with
the pre-shared key derived from your password by PBKDF2-HMAC-SHA256 at 600,000 iterations.
- Forward secrecy. Recording a transfer and cracking the password later no longer reveals
the files — each transfer has fresh ephemeral keys.
- Metadata is encrypted too. Filenames, file sizes, and the file count used to travel in
the clear; they're now inside the encrypted channel along with the contents.
- Tamper-evident. The plaintext version/mode preamble is bound into the Noise handshake as
the prologue, so modifying a single byte of it fails the handshake rather than going
unnoticed. This also closes the door on future downgrade attacks.
- Replaces the previous SHA-256/AES-256-GCM per-chunk scheme; Noise is now the sole cipher.
- Discovery announcements are signed with a key derived from the stretched PSK, so no fast
hash of your password ever goes on the air — every offline guess costs a full 600k-iteration
PBKDF2.
- Generated passwords are now 10 characters instead of 8 (~2⁵⁸ instead of ~2⁴⁷),
foreclosing a precomputed-table attack over the whole password space.
- Full design writeup: docs/shared-network-crypto.md. All three implementations (Rust,
Kotlin, Swift) are held together by shared known-answer test vectors.
Hotspot mode keeps WPA2 underneath, so it's now encrypted twice over.
macOS ↔ Linux Bluetooth now works
Hotspot transfers between a Mac and a Linux machine previously required manually pairing the
two in System Settings first, and often failed afterward with "Peer removed pairing
information".
Root cause: macOS advertises with a public address and dual-mode flags, and BlueZ's bearer
tiebreak prefers classic BR/EDR on a tie — so Linux was connecting over classic Bluetooth,
which macOS serves no GATT over. Linux now bonds over an LE socket first, which pins the
bearer to LE permanently, and keeps the bond for macOS peers so their rotating address stays
resolvable. Pairing also surfaces the 6-digit code in the app for confirmation (real MITM
protection), and declining now aborts the transfer cleanly instead of hanging.
Other fixes and improvements
Desktop (Windows/Linux)
- File selection now comes first everywhere: hotspot joiners pick files and then get
prompted for the password, instead of needing the host's password before the file dialog
would open.
- The UI recovers if a transfer task panics or is aborted, instead of freezing (#118).
- The Windows firewall UAC prompt no longer appears on every transfer (#129). The check
for an existing rule passed the rule name to netsh with literal quotes around it, so it
never matched and the rule was re-added every time. Both rules are also added under a
single elevated command now, so the one-time prompt is one prompt, not two.
- Windows Bluetooth: recovers from GATT enumeration failures (0x8000FFFF) against
already-paired iPhones — retries, then re-pairs once within the same transfer, rather than
failing the run. Advertising is now explicitly stopped after the credential exchange.
- Linux: stale flyingCarpet_* NetworkManager connections are pruned at startup and the
hotspot is torn down on window close (#51).
- Linux Bluetooth: cached BlueZ entries are purged and discovery is LE-only, so a
previously-paired device (an audio device, say) can no longer be picked up as the transfer
peer and then fail with "Could not find service UUID on scanned device" (#106).
- The WiFi Direct failure message now points at shared network mode (#115).
- Edge/WebView2 no longer offers previously typed transfer passwords in an autofill dropdown.
Android
- Fixed an intermittent iOS→Android hotspot failure ("Empty key" crash or a silent stall)
caused by a BLE credential-exchange race across two GATT connections.
- Fixed a stuck hotspot flag that made repeat transfers hang with "hotspot already running";
GATT client and server are now properly closed between transfers.
- Successful shared-network receives no longer end with a spurious
"Discovery error: StandaloneCoroutine was cancelled".
- Output box auto-scrolls; the transfer log survives screen rotation (it previously could
overflow the Binder transaction limit and vanish); every line is mirrored to logcat under
the tag FlyingCarpet, so a full transfer log can finally be pulled with
adb logcat -s FlyingCarpet for bug reports (#130).
- Bluetooth pairing that's declined or fails now aborts the transfer instead of waiting
forever; failed GATT reads no longer propagate empty values as the peer's OS or password.
- Missing Bluetooth permissions are distinguished from missing hardware, and the switch
stays usable so you can re-grant (#101).
- Password-prompt dialog buttons are readable in dark mode.
Security hardening
- Received filenames are sanitized against path traversal on all five platforms before
touching the filesystem.
- Header values from the peer (file count, filename length, chunk size) are bounds-checked
before they're used to size allocations.
- Fixed a Windows WiFi-profile XML injection via the SSID/password fields.
- Dependency updates closing 6 Dependabot advisories, including CVE-2026-42184 in Tauri
(is_local_url() misclassifying remote URLs as trusted local origins on Windows/Android,
allowing a remote page to invoke local-only IPC commands). Also CVE-2026-25727 (time),
CVE-2026-25541 (bytes), and fixes in serde_with and rand.
Send Folder is consistent everywhere
Sending a folder now recreates that folder inside the destination the receiving device
chose, with the contents inside, on all five platforms. Previously only macOS did this —
everywhere else the folder's contents were dumped loose into the destination. Sending
individual files is unchanged: they still arrive flat.
This also fixes sending a folder that contains sub-folders from Android to Windows or Linux,
which used to fail outright, and two cases where the desktop app aborted a transfer:
selecting a folder whose top level holds only sub-folders, and dropping two folders (or
files from two different directories) at once.
The in-app instructions were also wrong about how to send a folder — they told you to drag it
onto the window, which Android and macOS have no handler for, and which is not something a
screen reader user can do (#122). Each platform's help text now describes the control it
actually has, and says what arrives on the other end.
---
GitHub issues
Nine open issues are resolved or materially addressed by this branch, plus three closed
feature requests that v10 actually delivers. Only four (#51, #101, #115, #118) are cited in
commit messages — the rest were matched by reading the issue against the code, so the
confidence column matters.
| # | Title | Fixed by | Confidence |
|---|---|---|---|
| #101 | Bluetooth Button is Greyed Out | a85d2c4 | Fixed |
| #118 | Windows receiver freezes | a85d2c4 | Fixed |
| #124 | Support wired connection on one device | d4883ea | Fixed |
| #129 | Firewall UAC prompt every time | 25050cb, 04da20b | Fixed |
| #131 | Android to iOS not working | shared network mode | Fixed (you already told the thread this was the plan) |
| #106 | Bluer: GATT services not resolved | 967ed6b | Likely — ask reporter to retest |
| #51 | Does not clean up after itself (Debian) | a85d2c4 | Partial — no uninstall purge |
| #115 | Failed to start WiFi Direct AP | a85d2c4 + shared network | Worked around, not fixed |
| #130 | WiFi drops midway on large transfers | shared network, 478ee4d | Partial + diagnosis unblocked |
Closed feature requests v10 delivers — worth a courtesy follow-up, since the requesters never
got an answer: #93 (detect same network,
skip the hotspot), #61 (static network
option), #122 (folder-select button for
screen reader users).
Notes on the non-obvious matches
#129 was a real bug, not a design choice. check_for_firewall_rule built its query asformat!("name=\"{}\"", file_name) and passed it to process::Command as one argument, so
netsh searched for a rule whose name literally contained quote characters. It never
matched, the app concluded the rule was missing, and re-added it — raising UAC on every
single transfer. Fixed in 25050cb; 04da20b then collapsed the two netsh invocations
into one elevated cmd.exe so the first run costs one prompt rather than two.
#106's log identifies its own cause. The device it connected to advertises service UUIDs1101/110b/110c/110d/110e/111e — Serial Port, Audio Sink, AV Remote Control,
Handsfree. That's a classic Bluetooth audio device, not the reporter's iPhone. 967ed6b
records that bluer's discover_devices() pre-seeds results with every device BlueZ already
knows, ahead of the discovery filter, so a previously-paired peripheral could be returned as
the peer. The same commit purges those cached entries, scans LE-only, and retries/rescans on
enumeration failure.
#115 is not fixed and shouldn't be closed as such. "Failed to start WiFi Direct AP" is a
card/driver limitation. v10 only offers a route that avoids it.
Adjacent open issues — do NOT close with this release
- #109 Resume after lost connection —
shared network mode makes drops rarer, but there's still no resume.
- #58 Log clearing / newest-first
ordering / VPN warning — Android got autoscroll and logcat mirroring, but none of the three
asks.
- #133 Tighter UI + user-chosen password
— the UI half is untouched. The password half is a deliberate no: single-use CSPRNG
passwords are what make the "an offline crack is worthless" argument hold
(docs/shared-network-crypto.md §7). Worth answering kindly and explaining why, rather than
leaving it open indefinitely.
- #134 send/receive text,
#81 share sheet,
#75 adaptive icon,
#62 Android 5GHz hotspot — untouched.
---
Draft issue responses
Copy-paste ready. Post after the release is live so the download links work. Where a
response asks the reporter a question, that's deliberate — several of these are worth
confirming before closing.
#118 — Windows receiver freezes
Should be fixed in v10.
> The transfer task could panic or be aborted without ever re-enabling the UI, which left the
window looking frozen — the app was still running, but every control stayed disabled. v10
emits the re-enable from a drop guard so the UI recovers even when the transfer task dies
unexpectedly, and the setup paths that used to panic now print an error instead.
> One thing that would help me confirm: when it freezes, is the window unresponsive to clicks
entirely, or are the controls just greyed out and unclickable? Those are two different
problems and it's the second one I've fixed. If it's the first, and especially in the "kept
open for a long time" case rather than "after completing a transfer", I'd like to keep this
open.
#101 — Bluetooth button greyed out
Fixed in v10.
> The app was treating "Bluetooth permissions haven't been granted yet" and "this device has
no Bluetooth hardware" as the same state, and showed "Device can't use Bluetooth" for both.
That's also why the switch came to life after you tapped "Select file" — that's the point
where the permission request actually fired.
> v10 tells the two apart, keeps the switch tappable so it can re-request the permission, and
re-checks when the app returns to the foreground, so granting it in Settings now takes
effect without restarting the app.
#129 — Firewall UAC prompt every time
Fixed in v10 — and it was a bug, not a design decision. The rule was only ever meant to be
added once.
> The check for "do I already have a firewall rule?" passed the rule name to netsh withliteral quote characters around it, so netsh went looking for a rule whose name actuallycontained quotes. It never matched, so the app concluded the rule was missing and re-added
it — every transfer, hence the prompt every transfer. v10 passes the name unquoted and finds
the existing rule.
> One caveat: v10 adds a second (UDP) rule for shared-network discovery, so the first run
after upgrading will prompt once to add it. That's a single prompt covering both rules now
rather than one each. After that it should be silent.
#124 — Wired connection on one device
This works in v10.
> Shared Network mode transfers over a network both devices are already on, and it supports
wired interfaces explicitly — so your exact setup (Windows on ethernet, Android on WiFi,
same network) is supported. The interface picker lists wired adapters alongside wireless
ones, labelled with their IP.
> The "not connected to WiFi" error you hit came from hotspot mode needing a WiFi card to host
the hotspot with. In shared network mode nothing is hosted, so that requirement is gone.
#131 — Android to iOS not working
v10 is the fix for this, and it's close to release.
> As I mentioned above, the iOS WiFi side was broken and the fix is the shared network mode
I've been building. Join both devices to the same WiFi network and start the transfer: the
receiving device shows a password (with a QR code) that you enter on the sender. No hotspot,
no Bluetooth, and the two devices' operating systems no longer need to be selected.
> @B5-SA this should cover Linux Mint ↔ iOS too. v10 separately fixes Linux failing to
enumerate GATT services over Bluetooth, which is likely what you hit on the pairing side —
see #106.
#106 — Bluer: GATT services have not been resolved
I think v10 fixes this, and your log is what convinced me — thank you for pasting the whole
thing.
> Look at the device it picked up: service UUIDs1101,110b,110c,110d,110e,
111e — Serial Port, Audio Sink, A/V Remote Control, Handsfree. That's a classic Bluetoothaudio device, not your iPhone. bluer's discover_devices() pre-seeds its results with everydevice BlueZ already knows about, before the discovery filter applies, so a
previously-paired peripheral could come back as the "peer" — and naturally it has no Flying
Carpet GATT service on it.
> v10 scans LE-only, purges unpaired cached BlueZ entries carrying our service UUID before
discovery starts, retries service enumeration, and removes + rescans once if it still fails.
Would you be willing to retest once v10 is out?
#51 — Does not clean up after itself (Debian)
Partly addressed in v10, and I'd rather be straight about which part.
> Fixed: Flying Carpet no longer accumulates flyingCarpet_* connections as you use it.v10 prunes stale ones at startup and tears the hotspot down when the window closes, so a
crashed or force-quit transfer doesn't leave one behind indefinitely.
> Not fixed: there's still no uninstall-time purge. The AppImage has no uninstall hook to
attach one to, and the.debwould need apostrmscript.
> Before I add a --purge flag people would have to know exists: would the startup pruninghave been enough for your case, or were the leftovers specifically a problem after you'd
uninstalled? If it's the latter I'll do thepostrmscript for the.debat least.
#115 — Failed to start WiFi Direct AP
v10 gives you a way around this, though I want to be clear it isn't a fix for the underlying
problem.
> "Failed to start WiFi Direct AP" means the WiFi card or its driver won't host a software
access point. That's a hardware/driver limitation Flying Carpet can't work around, which is
why the message says what it says.
> What v10 adds is Shared Network mode: if both devices are already on the same WiFi or wired
network, no hotspot is created and the WiFi Direct path is never used. The error message now
points there too. Worth a try when v10 lands.
#130 — Drops the WiFi midway during large transfers
Two things in v10 for this.
> The direct one is Shared Network mode: if both devices are already on the same network, no
hotspot is created, so there's no ad-hoc WiFi connection to drop midway. For multi-GB
transfers from Android that's the path I'd recommend.
> The other is the thing you actually asked for — "I can't find any way to log/share error out
of the box as an end user." Fair, and fixed. Every line the Android app prints now also goes
to logcat under the tag FlyingCarpet, so you can pull a full transfer log withadb logcat -s FlyingCarpet. The log also survives screen rotation now; it could previouslybe wiped mid-transfer.
> v10 also fixes an intermittent Bluetooth credential-exchange race on Android, which may well
be the "hit and miss" half of what you were seeing.
#93 (closed) — Detect same network, avoid the hotspot
Following up a year and a half later: this is shipping in v10, more or less exactly as you
described it. If both devices are on the same network there's no hotspot at all — and yes,
that makes transfers to desktops without WiFi cards work, since wired interfaces are
supported.
#61 (closed) — Static network option
Following up: v10's shared network mode should remove the need for this. Instead of a fixed
SSID and password per peer, if both devices are already on a network there's no hotspot to
name — the receiving device shows a one-time password (with a QR code) and that's the only
thing to type. The long SSID + password entry for Mac ↔ Android goes away entirely.
#122 (closed) — Folder-select button for screen reader users
Following up, because v10 improves this and you deserved a better answer at the time.
> There's a "Send Folder" checkbox now, so sending a folder no longer requires drag-and-drop.
More to the point, the instructions text still said "To send a folder, drag it onto the
window" — which was exactly the wrong thing to tell a screen reader user, and it stayed that
way far too long. It now describes the checkbox instead.
> v10 also makes a sent folder arrive as a folder on the receiving device on every platform.
Previously most platforms scattered the contents loose into the destination folder, which I
imagine was its own kind of unpleasant to sort out without sight.
---
Ship checklist
Blockers — must resolve before tagging
- [x] ~~Unify Send Folder across all five platforms~~ — done 2026-07-24; all five now recreate
the folder on the receiving end. Code landed in both repos; see
docs/send-folder-behavior.md. Still needs the Tier 6 hardware rows run.
- [x] ~~Bump Apple passwords to 10 characters~~ — already done: generatePassword() returns 10
(shared/Transfer.swift), iOS prompt requires 10, macOS uses minLength: 10 for shared
network and 8 for hotspot join (correct — an Android host's WPA2 passphrase isn't ours
to size).
- [x] Run the Tier 6 Send Folder rows on hardware — core rows done 2026-07-24/25
(Windows/Linux/Android send; all five receive). The remaining edge rows (iOS and
macOS send, flat multi-file, multi-directory drops, duplicate-name, Unicode
sub-folder) are still open in the test plan and tracked under "Finish the test
plan" below.
- [x] Build the Apple repo. shared/Transfer.swift and the iOS storyboard changed and
have not been compiled here (no Mac).
- [x] One more Mac test run (⌘U on the macOS target). The Swift discovery-announcement
KAT (DiscoveryTests, Apple commit 8d73710) was written on Windows 2026-07-25 and
has never been compiled. Tier 0's "Apple unit tests pass" checkmark predates it.
- [x] ~~Confirm both repos report the same protocol version constant~~ — done; re-verified
2026-07-25 in the test plan's Tier 0 (Rust/Kotlin/Swift all write the same 8-byte
big-endian 10).
- [ ] Finish docs/v10-release-test-plan.md — Tier 2 is green; Tiers 3–6 and the release
gate remain (Tier 3 lacks only wrong-password-over-hotspot).
- [x] ~~Re-run desktop smoke tests after the Tauri 2.9.5 → 2.11.1 / wry 0.53 → 0.55 bump~~ —
covered: the bump landed 2026-07-23 and the Tier 0 desktop builds plus every Tier 2
interop row (Windows and Linux in both roles, both modes) ran on hardware 07-24/25,
after it.
Versions and metadata
- [x] ~~Apple repo: bump iOS and macOS CFBundleShortVersionString to 10.0~~ — both app
targets are at MARKETING_VERSION = 10.0.0 (verified 2026-07-25 and re-checked).
CURRENT_PROJECT_VERSION = 1 is fine for a new marketing version unless a 10.0.0
build was already uploaded to App Store Connect — bump it only in that case.
- [ ] Android: confirm versionCode 22 is greater than what's live on Play and F-Droid.
- [x] ~~Update the README's sideload APK filename~~ — now version-agnostic ("the APK is
available on the releases page"), so it can't go stale again.
- [ ] Decide whether tauri.conf.json's stale icons/icon.icns entry stays (per CLAUDE.md it
is intentionally left alone — confirm, don't silently "fix").
Screenshots and store assets
Every screenshot in screenshots/ predates the mode switch, the interface dropdown, and the
password-box removal.
- [ ] screenshots/windows.png — retake showing the Shared Network / Hotspot mode switch
- [ ] screenshots/linux.png — retake; show the interface dropdown with IP labels
- [ ] screenshots/mac.png — retake with the current segmented-control layout
- [ ] screenshots/android.png — retake (mode switch + Send Folder checkbox)
- [ ] screenshots/ios.png — retake, ideally showing shared network mode
- [ ] Take a receiver-side password + QR screenshot — it's the single most explanatory new
screen and there's currently no shot of it anywhere
- [ ] Google Play: updated phone screenshots + feature graphic
- [ ] App Store: updated screenshots for every required device size (iPhone 6.9"/6.5", iPad
if listed)
- [ ] F-Droid metadata: screenshots and changelog entry
- [ ] Re-record the demo video (currently https://youtu.be/52Xkrx2BXrg, hotspot-only).
The minimum is done: the README now notes it predates v10 — so this no longer blocks
release, but the note is a stopgap.
Documentation
- [x] ~~Fix the stale "drag it onto the window" folder instructions~~ — done; each platform's
help text now describes what it actually supports and states that a sent folder is
recreated on the receiving end.
- [x] ~~README: add a Shared Network mode section with the receiver-shows-password flow~~ —
done 2026-07-25 (Use section: Shared Network flow, Hotspot flow, folder behavior).
- [x] ~~README: mention that a sent folder is recreated inside the receiver's destination~~
- [x] ~~README: state plainly that v10 can't talk to v9 and both devices must be updated~~ —
breaking-change warning added under the headline.
- [x] ~~Verify README crypto Q&A matches the shipped design~~ — verified 2026-07-25 against
code: NNpsk0 suite, 600k PBKDF2, prologue binding, discovery key from the stretched
PSK all match; get_key_and_ssid's callers all discard the key half, so SHA-256 of
the password really is SSID-only.
- [x] ~~Check ARCHITECTURE.md is accurate for the final state of the branch~~ — reviewed
2026-07-25; accurate (role axes, peer-OS-selection gating, shared-network section,
BLE-is-hotspot-only rationale all match the code). One wording slip fixed ("the v10
inner per-chunk AES" → the AES v10 removed).
Build and packaging
- [ ] Windows: .msi installer + standalone FlyingCarpet.exe, both signed
- [ ] Linux: .AppImage + .deb
- [ ] macOS: .dmg, signed and notarized; verify Gatekeeper on a clean machine
- [ ] Homebrew cask update (brew install flying-carpet)
- [ ] Android: signed release APK for sideloading + AAB for Play
- [ ] iOS: App Store build submitted (start early — review latency gates the announcement)
- [ ] Verify each artifact launches on a machine that has never had Flying Carpet installed
Release mechanics
- [ ] Merge shared-network → main in both repos, close together (they must stay
wire-compatible)
- [ ] Tag v10.0.0 in both repos
- [ ] Publish the GitHub release with these notes and all binaries (including the Apple ones,
which ship from this repo)
- [ ] Google Play rollout — consider staged (20% → 100%)
- [ ] F-Droid: confirm the build recipe picks up the new tag
- [ ] Post the drafted issue responses (see "GitHub issues" above) once the release is live
and the download links work
- [ ] Close #101, #118, #124, #129, #131 outright
- [ ] Leave #51, #106, #115, #130 open pending reporter confirmation — each response asks
a question, and #115 in particular is worked around rather than fixed
- [ ] Comment on closed #93, #61, #122; leave them closed
Post-release
- [ ] Watch for v9↔v10 confusion reports; the mismatch message is the first line of defense
- [ ] Start on docs/post-v10-maintenance.md — the windows crate 0.44 pin first
- [ ] Re-check the glib 0.18.5 advisory at the next Tauri upgrade (Linux/GTK only)
---
V10 Release Test Plan
Flying Carpet v10 — Release Test Plan
Branch: shared-network (both repos: FlyingCarpet, FlyingCarpetApple).
Goal: validate everything v10 introduces before release, efficiently, by gating on
builds/KATs first and exploiting the symmetry of the wire protocol.
What v10 introduces (scope to validate)
- Shared Network mode — transfer over an existing WiFi/wired network instead of a
hotspot. HMAC-authenticated discovery, manual password (receiver generates + shows QR,
sender types/scans), no Bluetooth, TCP receiver = server on port 3290.
- Noise encryption over both modes — Noise_NNpsk0_25519_ChaChaPoly_SHA256, PBKDF2
PSK, plaintext version/mode preamble bound into the Noise prologue. Replaces the old
inner AES-GCM. Now encrypts all metadata (filenames, sizes, count), not just contents.
- Protocol bumped to v10 (breaking) — a v10 device talking to v9 must show a clear
version-mismatch message, not hang or misbehave.
- Discovery key derived from the PBKDF2-stretched PSK (no fast hash of the password
on the air). Generated passwords bumped 8 → 10 chars.
- Interface chooser — dropdown labeled with IP, hides unusable interfaces, supports
wired interfaces (shared network).
- macOS ↔ Linux Bluetooth fix — Linux LE-bonds before connecting; pairing agent
confirms the 6-digit code in-UI. macOS RPA / "CBError 14" bond-keeping.
- Bug-fix bundle — UI-freeze drop guard, stale hotspot cleanup (#51), Android
permissions-vs-hardware (#101), Windows profile XML injection fix, receive-path hardening.
- Late lifecycle fixes (this branch tail) — Android hotspot flag + GATT client/server
close; Apple BLE connection/service teardown; coroutine-cancellation no longer reported
as an error. See "Lifecycle status" below.
- Send Folder is consistent across all five platforms — a sent folder is now recreated
inside the receiver's chosen destination everywhere, instead of only on macOS. Fixes an
Android→desktop failure and two desktop abort cases along the way. Retest rows in Tier 6;
details in docs/send-folder-behavior.md.
Platforms & environment
Five platforms: Windows (10+), Linux (AppImage + .deb), macOS, iOS,
Android (API 29+). Apple devices cannot host a hotspot, so Apple↔Apple must use
shared network. Ideally have two Android and/or two desktops available for same-OS runs.
---
Tier 0 — Build & static gates (do first)
Cheapest, and catches the biggest current risk: the Apple lifecycle changes have never
been compiled.
- [x] Android: ./gradlew assembleDebug (and lintDebug — only the pre-existing
MainActivity:94 MissingPermission finding is expected)
- [x] Rust core: cargo build for Windows and Linux; cargo clippy clean — "clean" means
no errors. cargo clippy --workspace --all-targets on Windows emits 23 style lints in
core and 4 in the app (is_none() over == None, Ok(…)?, expect with a format
arg, arg counts); all pre-existing, none in v10 code paths.
- [x] Tauri desktop app builds on Windows and Linux (cargo tauri build)
- [x] iOS builds in Xcode (FlyingCarpetApple)
- [x] macOS builds in Xcode (FlyingCarpetApple)
- [x] Android unit tests pass: NoiseUnitTest, DiscoveryUnitTest
- [x] Rust unit tests pass: cargo test (incl. official_noise_test_vector,
wrong_password_fails_handshake, tampering_is_detected, round_trip_small_and_large,
and the utils::selection_tests folder-naming cases)
- [x] Apple unit tests pass (Noise KATs, incl. cacophony vector + app KATs)
- [x] Both repos report the same protocol version constant (wire-compat check).
Re-verified 2026-07-25: Rust MAJOR_VERSION: u64 = 10 (core/src/lib.rs:83), Kotlin
MAJOR_VERSION: Long = 10 (MainViewModel.kt:56), Swift VERSION: UInt8 = 10
(shared/Transfer.swift:17). The Swift type is narrower but goes out as
Data([0,0,0,0,0,0,0,VERSION]), so all three write the same 8 big-endian bytes.
---
Tier 1 — Smoke (prove the pipeline, 2 transfers)
- [x] Shared network: iOS → Android (small file)
- [x] Hotspot: iOS → Android (small file)
---
Tier 2 — Interop matrix
The wire protocol (discovery → preamble → Noise → files) is symmetric, so a directed
cycle covers each platform as both sender and receiver without testing every pair.
W=Windows, L=Linux, M=macOS, I=iOS, A=Android.
Shared network — core cycle (each platform sends once, receives once)
- [x] W → L
- [x] L → M
- [x] M → I
- [x] I → A
- [x] A → W
- [x] I → M (Apple ↔ Apple, the case that requires shared network)
- [x] M → M or A → A (same-OS sanity, if a second device is available)
Hotspot — each BLE stack pairs with each other, each non-Apple platform hosts
Apple always guests; the peer hosts. Confirm the 6-digit pairing code and the transfer.
- [x] A → I and I → A (Android hosts; Android ↔ Apple BLE)
- [x] A → M and M → A (Android hosts; Android ↔ macOS BLE)
- [x] W → I and I → W (Windows hosts; Windows ↔ Apple BLE)
- [x] L → M and M → L (Linux hosts; macOS ↔ Linux — the v10 BLE fix)
- [x] W → L and L → W (desktop ↔ desktop hosting)
- [x] W → A and A → W (Windows ↔ Android BLE)
- [x] L → A and A → L (Linux ↔ Android BLE)
If any single hop fails, expand only that pair to localize it. Passing the cycle +
the hotspot pairs means every platform's discovery, Noise, BLE, and hotspot code has
run in both roles.
---
Tier 3 — Cryptography & protocol validation
- [x] KATs green on all three implementations (Rust, Android, Apple) — the real
cross-platform interop guarantee. 2026-07-25: Rust (28 pass, 2 hardware-ignored) and
Android (NoiseUnitTest 10, DiscoveryUnitTest 4) re-run; Apple's suite was run on
a Mac in Tier 0 and its sources are unchanged since. All nine shared vectors — PSK,
discovery key, app handshake msg1/msg2/record, prologue + its msg1/msg2/record — are
byte-identical in core/src/noise.rs, NoiseUnitTest.kt, and
macOS/FlyingCarpetTests/FlyingCarpetTests.swift, as is the discovery announcement
vector between Rust and Kotlin.
- Swift had the discovery key KAT but no discovery announcement vector — the
93-byte layout + HMAC was pinned only between Rust and Kotlin. Closed 2026-07-25:
DiscoveryTests in macOS/FlyingCarpetTests/FlyingCarpetTests.swift now asserts the
same vector on serialize, on deserialize (field by field, plus HMAC verify), and on a
flipped bit in the signed prefix. Written on Windows and never compiled — it needs
one run on a Mac before Tier 0's Apple row can be re-checked.
- The iOS test target still holds only the Xcode template tests; the Noise and
discovery KATs live in the macOS target. Both projects compile the same
shared/Noise.swift and shared/Discovery.swift, so iOS's implementations are
covered as long as the macOS suite is the one that gets run.
- [x] Wrong password (shared network): enter a mismatching password → clear
"could not establish a secure connection / check the password" message, no hang
- [ ] Wrong password (hotspot): same, via the BLE-exchanged password path
- [x] Version mismatch: run a v10 build against a v9 build → clear version-mismatch
message on both ends, no hang or garbage
- [x] Preamble tamper (if a test hook exists / via unit test) — handshake fails,
transfer aborts. Unit-tested on all three: prologue_mismatch_fails_handshake (Rust),
prologueMismatchFailsHandshake (Kotlin), testPrologueMismatchFails (Swift). Each
flips one bit of the responder's transcript and asserts the handshake fails even
though the passwords match. tampered_handshake_is_detected covers a corrupted
handshake message on all three as well.
- [x] Metadata confidentiality sanity: confirm filenames/sizes are no longer sent in the
clear (packet capture optional; primarily covered by Noise KATs). Verified by
inspection 2026-07-25: everything after the preamble goes through the
TransferStream::Encrypted handle — file count (lib.rs), then filename length,
filename bytes, size, per-chunk lengths and hashes (sending.rs:87-117). The only
plaintext writes are the version/mode preamble, which the prologue binds, and the
TransferStream::Plain fallback used solely to report a version/mode mismatch.
---
Tier 4 — Lifecycle regression (the fixes on this branch tail)
Each row has a specific repro that previously failed — test the repro, not just "works."
- [x] Android repeat transfer: iOS → Android twice in a row, no restart →
transfer 2 stands up its hotspot; no "hotspot already running" in logcat; the
Bluetooth switch stays on between legs (2026-07-25: a leftover pre-bond GATT
client received iOS's teardown Service Changed after stop() and flipped it off —
field guide §2c; expect "Ignoring service change after teardown" in logcat instead)
- [x] Android GATT teardown: back-to-back hotspot transfers → no phantom
"Wrote OS to peer" / "Device connected" churn between transfers (logcat)
- [x] Apple central teardown: macOS/iOS as receiver (central), then a second
transfer → no leaked connection; second transfer clean
- [x] Apple peripheral service: Apple as sender twice in a row → service
re-registers; second advertise/read works
- [ ] Cancel mid-transfer (Android, hotspot) → UI re-enables; no spurious
"Transfer error: … cancelled"
- [ ] Cancel mid-transfer (Android, shared network) → no spurious "Transfer error"
or "Discovery error: … cancelled"
- [x] Successful shared-network receive (Android) → no "Discovery error" after
"Transfer complete"
- [ ] Cancel mid-transfer (each desktop) → UI recovers (drop-guard); hotspot torn down
- [ ] Stale BLE pairing recovery: forget the peer on one side only, retry → re-pairs
cleanly (do not need to forget both). Known to fail as of 2026-07-25 — this is the
unresolved half of the Windows↔Linux failure; the re-pair returns
Pairing result: Failed and keeps failing on subsequent attempts. Capture
bluetoothctl paired-devices and btmon on the Linux side when testing this.
- [x] Bidirectional BLE hotspot, Windows ↔ Linux (the 2026-07-25 repro): Windows → Linux,
then immediately Linux → Windows without restarting either app. Leg 2 must reuse the
bond and enumerate services. Previously leg 1 made Linux delete its half of the bond,
so leg 2 got an empty GATT service list and then failed to re-pair permanently. Confirm
bluetoothctl paired-devices on Linux still lists the Windows box after leg 1.
- [x] Both bond provenances, Windows ↔ Linux. Run the pair twice from fully unpaired,
once in each starting order, because the two produce different cached state on Linux
and only one of them used to work:
- [x] unpaired → Windows → Linux first (Linux bonds as central) → then Linux → Windows
- [x] unpaired → Linux → Windows first (Linux bonds as peripheral) → then
Windows → Linux. This is the order that hung: Linux's cache entry for the peer had
no Flying Carpet UUID, and the scan only reacted to DeviceAdded, which never
fires twice for a bonded peer. Expect Found peer … by re-reading known devices
in the Linux stdout.
- [ ] Then a third leg in each case, to confirm repeat transfers keep working
- [ ] Bidirectional BLE hotspot, Linux ↔ Android and Linux ↔ macOS, same pattern —
Linux no longer removes any peer's bond, so all three pairings need one clean
round trip. macOS was already exempt and should be unchanged.
- [x] Android post-bond bearer (TRANSPORT_AUTO → TRANSPORT_LE, fixed 2026-07-25):
Android ↔ macOS and Android ↔ Linux/Windows, from fully unpaired, so the post-bond
connectGatt runs against a dual-mode peer right after cross-transport key derivation.
This was the direct analogue of the Windows↔Linux br-connection-canceled bug and had
never been exercised. Failure looks like a connect that succeeds with no Flying Carpet
service.
- [x] Android as central, twice in a row, bonded — Android↔Windows and Android↔Linux,
Android receiving both legs, no app restart between them. This row was written when
Android never invalidated its GATT cache and predicted a silent hang; both halves of
that prediction have since been fixed (onServiceChanged now re-discovers, gated on
exchangeComplete, and every onServicesDiscovered exit reports and calls
bluetoothFailed()). Expected now: leg 2 logs "Services changed" followed by a
successful re-discovery and a normal transfer. A "Did not find the Flying Carpet
service" abort or any hang after "Discovered services" is a regression in that fix,
not the previously predicted stale-cache hang. See docs/ble-bond-asymmetries.md.
- [ ] Poisoned-bond self-heal still works (Linux as central): the deliberate
remove_device on characteristic-discovery failure was kept; confirm a genuinely bad
bond still recovers via the "retrying with a fresh pairing" path.
- [ ] Android rotation mid-transfer: rotate during a multi-file transfer (enough files
that the log has scrolled) → transfer keeps running; the log survives whole, with no
duplicated or missing line at the seam and no truncation; auto-scroll still follows new
lines afterward; progress bar and button states are preserved. The log now lives in the
ViewModel instead of the saved-state Bundle, so this also covers the
TransactionTooLargeException that a long transfer's log previously risked on rotation.
Rotate a second time after the transfer completes to confirm the finished log persists.
---
Tier 5 — Platform-specific
- [x] Android: LocalOnlyHotspot works on target device (known-broken on some
Xiaomi/MIUI/HarmonyOS — note device model tested)
- [ ] Android: deny then re-grant Bluetooth permission → switch stays usable, recovers
on resume (#101); password-prompt dialog readable in dark mode
- [ ] iOS: after a hotspot transfer, no leftover flyingCarpet_* Wi-Fi config; force-
quit mid-transfer then relaunch → stale config removed on startup
- [ ] Linux: force-kill mid-hotspot, relaunch → stale flyingCarpet_* NetworkManager
connection removed on startup (#51)
- [ ] Windows: WiFi Direct AP starts/stops cleanly; SSID with special characters does
not break profile handling (XML-injection fix); firewall prompt handled
- [ ] macOS: long transfer doesn't silently drop when macOS switches back to an
internet network (known caveat — confirm behavior/messaging)
- [x] Interface chooser (desktop): dropdown lists interfaces with IPs, hides unusable
ones, wired interface works in shared network mode
- [ ] Shared network over a manual iPhone Personal Hotspot joined by both devices
(documented Apple↔Apple path)
---
Tier 6 — Robustness / edge cases
- [x] Multi-file transfer (2+ files)
- [ ] Large single file (> 2 GB if feasible; sustained multi-record Noise streaming)
- [ ] Empty / zero-byte file
- [ ] Filename with Unicode / spaces / emoji
- [ ] Peer never starts → no infinite hang; cancellable; clean message
- [x] Receiver started long before sender → still connects (no premature timeout)
- [ ] Sender and receiver both pick the same mode (both Send / both Receive) → clean
"both sides picked the same mode" error
- [ ] Two transfers in a row in shared network mode on every platform (mirror the
Android repeat-transfer regression)
Send Folder — retest on all five (behavior changed; previously inconsistent)
Every platform now sends a chosen folder so the receiver recreates that folder inside the
destination they picked, with the contents inside. Before this change only macOS did
that; Windows, Linux, Android, and iOS dumped the contents loose into the destination, and
Android→desktop failed outright for any folder with sub-folders. Rationale, the old
per-platform behavior, and the fixes: docs/send-folder-behavior.md.
Use one test folder throughout, so results are comparable. It must exercise every case that
used to break:
TestFolder/
top.txt <- file directly inside the selection
Nested/inner.txt <- one level down
Nested/Deeper/deep.txt <- two levels down
OnlyDirs/x/1.txt <- selection level with NO loose files (used to flatten or fail)
OnlyDirs/y/2.txt <- sibling of the above (used to abort: "Strip prefix error")Pass = destination contains TestFolder/ with all five files at the paths above, and
nothing loose in the destination root.
- [x] Windows sends TestFolder (Send Folder checkbox) → receiver gets TestFolder/…
- [x] Windows sends TestFolder by drag-and-drop onto the window → same result
- [x] Linux sends TestFolder (checkbox and drag-and-drop)
- [x] Android sends TestFolder (Send Folder checkbox) → to a Windows or Linux receiver;
this is the combination that used to fail with "Received invalid filename path"
- [ ] iOS sends TestFolder ("Send Folder" in the "Send from:" prompt)
- [ ] macOS sends TestFolder (choose the folder in the picker) → confirm no regression;
this is the one platform whose behavior did not change
- [x] Each of the five receives TestFolder from at least one other platform, and the
folder is recreated rather than flattened
- [ ] Plain multi-file selection still arrives flat (no folder created) on all five —
the fix must not wrap ordinary file sends in a directory
- [ ] Desktop: select/drop files from two different directories at once → all arrive,
flat, no "Strip prefix error" (previously aborted the transfer)
- [ ] Desktop: drop two folders at once → both recreated side by side (previously aborted)
- [ ] Send a folder twice into the same destination → second copy lands under "(1) name"
siblings rather than clobbering
- [ ] Folder containing a file with Unicode / spaces / emoji in a sub-folder name
---
Lifecycle status (BLE + hotspot resource teardown)
Post-fix state. ✅ = correct; ⚠️ = fragile/by-design; see caveats.
| Platform | Hotspot torn down | Central: scan stopped | Central: connection closed | Peripheral: advertising stopped | Peripheral: service removed |
|---|---|---|---|---|---|
| Android | ✅ fixed | ✅ | ✅ fixed | ✅ | ✅ fixed |
| Windows | ✅ | ✅ | ⚠️ persists (by design) | ✅ fixed (explicit StopAdvertising) | ✅ (registration released on drop) |
| Linux | ✅ | ✅ (RAII) | ✅ (RAII + remove_device) | ✅ (explicit drop) | ✅ (explicit drop) |
| iOS | ✅ | ✅ | ✅ fixed | ✅ | ✅ fixed |
| macOS | ✅ | ✅ | ✅ fixed | ✅ | ✅ fixed |
Verification status of the fixes:
- Android fixes: compile + lint verified; not yet hardware-tested (Tier 4 rows).
- Apple fixes: not yet built (Tier 0) and not tested (Tier 4 rows).
- Windows peripheral advertising now stops explicitly (StopAdvertising() +
RemoveAdvertisementStatusChanged); the one remaining ⚠️ is the device
connection/pairing persisting after a transfer, which is by design (Windows has
trouble re-enumerating already-paired devices, so "unpair after every transfer" is
intentionally disabled) and is pre-existing, not a v10 regression.
Hardware-tested 2026-07-24: Windows→iPhone hotspot (fresh pairing) passed, but the
reversed second leg (iPhone→Windows, reused bond) failed GATT service enumeration with
0x8000FFFF; a manual rerun with fresh pairing succeeded. The Windows central now
recovers from this automatically: on enumeration failure it retries enumeration (up to
3×, ~1 s apart), and only if the bond was reused and all retries fail does it unpair,
rescan, and re-pair (new PIN confirmation) within the same transfer instead of
aborting. Retest 2026-07-24: passed — reversed second leg reused the bond and
enumerated services on attempt 1 (leg-1 BLE link was still up, so no reconnect was
needed); no recovery rung fired, so the ladder itself remains field-unexercised. The
original failure is intermittent — if it recurs, the UI log's attempt/timing
diagnostics will show which rung fixed it. Root-cause investigation, sources, and the
recovery-ladder design: docs/windows-ble-gatt-0x8000ffff.md.
- Linux is the reference implementation (full RAII); no action.
Release read on the lifecycle front: the code gaps found in the audit are closed on
Android and Apple, so on paper every platform is ✅ except Windows' pre-existing ⚠️. But
"buttoned up" requires the verification: Tier 0 build of Apple + Tier 4 regressions on
Android and Apple must pass. Until then the fixes are unverified. Windows' remaining
items are not new in v10 and should not block release.
---
Release gate
- [x] Tier 0 fully green (hard blocker: Apple must build)
- [x] Tier 1 green
- [x] Tier 2 core cycle + hotspot pairs green
- [ ] Tier 3 green (wrong password + version mismatch are must-pass)
- [ ] Tier 4 green (lifecycle regressions — highest-risk new code)
- [ ] Tier 5 green or documented known-issue per platform
- [ ] Tier 6 green or documented
- [ ] Version strings bumped to 10 in all artifacts; changelog/README updated. Versions
checked 2026-07-25 and all agree: core/Cargo.toml, src-tauri/Cargo.toml, and
tauri.conf.json at 10.0.0; Android versionName "10.0.0" (versionCode 22); iOS and
macOS app targets MARKETING_VERSION = 10.0.0 (the 1.0 entries in both pbxprojs belong
to the test bundles, which don't ship). README covers Shared Network + Noise. Left
unchecked for the changelog only — the repo has no CHANGELOG.md, so if release notes
live on the GitHub Releases page, that's the remaining item.
- [ ] Both repos tagged in lockstep (wire-compatible)
---