{"owner":"spieglt","repo":"FlyingCarpet","hasSkills":true,"totalSkillsCount":3,"totalTokensCount":7809,"categories":["claude-rule","project-spec"],"hasMcp":false,"mcpConfig":null,"found":["Apple/CLAUDE.md","ARCHITECTURE.md","CLAUDE.md"],"skills":{"Apple/CLAUDE.md":"# Flying Carpet — Apple (iOS + macOS)\n\nThe **Swift** iOS and macOS apps. Port **3290** throughout, same wire protocol as the Rust core\nand the Android app. See the root `CLAUDE.md` for the repo-wide picture and\n`../ARCHITECTURE.md` for the connection role model.\n\n**Build only on a Mac.** Nothing here compiles as part of the Cargo workspace, and nothing in\n`core/` targets Apple platforms — the Swift code is a full independent port of the protocol, not\na binding over the Rust core. The **Rust core is the reference** this port is tested against.\n\n## Layout\n\n- `shared/` — protocol code shared by both apps (added to both Xcode targets):\n  - `Transfer.swift` — the orchestrator. `runTransfer()` entry point, connection-mode logic,\n    password generation, `Transfer.Delegate` protocol (the per-platform ViewController\n    implements it).\n  - `Discovery.swift` — shared network peer discovery: multicast + unicast UDP on port 3290\n    over raw POSIX sockets, then TCP on 3290.\n  - `Network.swift` — TCP read/write in a `TCPConnectionProtocol` extension; `TCPClient`\n    (hotspot) and `TCPConnectionWrapper` (shared). `AtomicBool` is defined here once.\n    Connections run on `networkQueue`, not main.\n  - `Noise.swift` — `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake + `buildPrologue`.\n  - `Send.swift` / `Receive.swift` — sending/receiving halves.\n  - `Bluetooth.swift` — BLE password exchange (**hotspot mode only**, see invariants).\n- `iOS/FlyingCarpet/` — iOS app. `ViewController.swift` (main UI + BLE + picker delegates),\n  `QRScanner.swift` / `QRScanner.storyboard` (camera QR scanning), `Base.lproj/Main.storyboard`.\n- `macOS/FlyingCarpet/` — macOS app. `ViewController.swift`, `SsidBox.swift`,\n  `Base.lproj/Main.storyboard`.\n\n## Build\n\n```\nDEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \\\n  xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build\n```\n\nFor a specific destination: iOS `-destination 'generic/platform=iOS Simulator'`, macOS\n`-destination 'platform=macOS'`.\n\n**Code signing:** `DEVELOPMENT_TEAM` is deliberately blank in both `project.pbxproj` files, so a\nfresh clone fails with *\"Signing for 'FlyingCarpet' requires a development team.\"* Either select\nyour team once in Xcode (target → Signing & Capabilities → Team; Xcode writes it back into\n`project.pbxproj` as a local modification) or pass it per-build without touching the file:\n\n```\nxcodebuild ... DEVELOPMENT_TEAM=YOURTEAMID -allowProvisioningUpdates build\n```\n\n**The protocol tests live in the macOS target.** `macOS/FlyingCarpetTests/FlyingCarpetTests.swift`\nholds 25 of them, including the cross-platform Noise and discovery known-answer vectors. Run that\ntarget when changing protocol code — the KATs are not duplicated in the iOS target.\n\n`iOS/FlyingCarpetTests/FlyingCarpetTests.swift` holds the 8 tests that can only be exercised on\niOS: the bounded Noise receive buffer (#142) on the platform whose receiver actually failed, and\nthe QR display layout (#141), which is UIKit and has no macOS counterpart. Run it against a\nsimulator:\n\n```\nxcodebuild -project iOS/FlyingCarpet.xcodeproj -scheme FlyingCarpet \\\n  -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \\\n  -only-testing:FlyingCarpetTests test\n```\n\nTwo things to know before running either:\n\n- **`-only-testing:FlyingCarpetTests`.** Without it the scheme also runs `FlyingCarpetUITests`,\n  which is untouched Xcode boilerplate that asserts nothing. On macOS it builds and launches a\n  separate `FlyingCarpetUITests-Runner.app`, which prompts for automation permission and re-prompts\n  on every rebuild, because an ad-hoc-signed bundle gets a new signature each time.\n- **A team on the app target alone breaks the macOS test bundle.** Selecting your team in Xcode\n  writes `DEVELOPMENT_TEAM` into the app target's configs only, and the test bundle then fails to\n  load into its host with *\"mapping process and mapped file (non-platform) have different Team\n  IDs\"*. Pass `DEVELOPMENT_TEAM=YOURTEAMID` to `xcodebuild` so every target agrees. Simulator runs\n  are unaffected.\n\n**SourceKit lies about single files.** Diagnostics that lint one file in isolation report bogus\n\"Cannot find type 'Transfer'/'Bluetooth' in scope\" and \"No such module 'UIKit'\" errors — the\ncross-file and cross-target types resolve fine in a real `xcodebuild`. Trust the build, not the\nisolated-file lint.\n\n## Architecture\n\n- **Two connection modes:** `hotspot` (one device hosts an ad-hoc Wi-Fi network) and\n  `sharedNetwork` (both already on the same LAN; discovery finds the peer over IP). Set from\n  `connectionModeSwitch` (Hotspot=0 / Shared Network=1) on both platforms.\n- **Receiver is the anchor.** In both modes the receiver generates the password, is the TCP\n  server (Noise responder), and (shared network) displays the password; the sender is the TCP\n  client (Noise initiator) and supplies the password.\n- **Apple-to-Apple only works in shared network mode.** Apple no longer lets hotspots be\n  configured programmatically, so hotspot mode is blocked for two Apple devices (error\n  `appleToAppleHotspotErrorMessage`). Use a normal Wi-Fi network or a manually-configured\n  Personal Hotspot the other device has joined.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\nThese mirror the root `CLAUDE.md`; the Swift-specific details are here.\n\n- **v10 = Noise.** Every transfer (both modes) runs the Noise NNpsk0 handshake; the PSK is\n  `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is\n  the sole cipher. v9 peers are rejected.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then\n  every preamble byte is bound into the Noise prologue. Both platforms of any pair must build\n  the prologue identically. Cross-platform KATs guard this — keep the Swift Noise tests\n  (`macOS/FlyingCarpetTests/FlyingCarpetTests.swift`) in sync with `../core/src/noise.rs` and the\n  Kotlin `NoiseUnitTest`; discovery vector in sync with `../core/src/discovery.rs`'s\n  `test_cross_platform_vector`.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer\n  (`generatePassword()` — 10 chars from a 57-symbol confusables-free charset, matching the\n  desktop/Android generators) and displays it; never reuse, never user-chosen, never remember.\n- **Bluetooth is hotspot-only.** Shared network exchanges the password manually (display +\n  type/QR); do **not** re-add BLE to shared mode. The BT switch is greyed out in shared network\n  mode on both platforms — every site that re-enables it goes through\n  `bluetoothSwitchShouldBeEnabled()` (`capable && connectionMode == .hotspot`), because\n  `toggleUI` and `toggleBluetoothUI` both used to restore it on hardware capability alone.\n  Apple-to-Apple can't pair iPhone↔Mac over BLE by design — exactly the pair that would need it.\n\n## Password exchange & QR codes (shared network mode)\n\nThe receiver generates and displays the password; the sender supplies it. To avoid typing, the\nreceiver can display it as a **QR code** and the sender can **scan** it.\n\n- **QR content format (must match across platforms):** shared network QR = the **bare\n  password**. Hotspot QR = `\"ssid;password\"`. Scanners split on `;`: >1 component ⇒ hotspot\n  (ssid + password), otherwise the bare password.\n- **Receivers display the QR** two ways (like Android/desktop): in the main-screen logo image\n  view (`logoImageView`, reset to the app logo in `toggleUI(transferRunning: false)`), and in a\n  popup. macOS uses CoreImage `CIQRCodeGenerator` (`qrCodeImage(from:size:)` → `NSImage`) in the\n  password `NSAlert`'s accessory view; iOS uses the same generator (→ `UIImage`) in a dedicated\n  `QRDisplayViewController` (a `UIAlertController` can't host an image). Both also show the text\n  password for a sender that can't scan.\n- **iOS sender** offers \"Scan QR Code\" in the password prompt (`promptForPassword`), routing to\n  `ScannerViewController` via the `goToQRScanner` segue; the scanned string flows through\n  `codeScanned(result:)`, which sets the password and starts the transfer. iOS can also still\n  type the password.\n- **macOS has no scanner** (Macs aren't portable and may lack a camera). So a macOS *sender*\n  types the password shown on the peer; iOS/Android senders can scan. Net: iOS↔macOS uses QR\n  when iOS is the sender; macOS-as-sender types.\n\n## Peer OS selection on Apple (why macOS has a `peerSwitch` and iOS doesn't)\n\nOn host-capable platforms the \"Select Peer OS\" control feeds `is_hosting(peer, mode)` — who\ncreates the hotspot (see `../ARCHITECTURE.md`, \"Peer OS Selection\"). **Apple devices never host\na hotspot** (no public API — the peer always hosts), so on Apple the peer's OS is *never* needed\nto decide hosting. That collapses the control's job to two leftovers, both **hotspot-mode-\nwithout-Bluetooth only**:\n\n1. **Is the peer Android?** An Android hotspot's SSID is OS-assigned and not derivable from the\n   password, so the joiner must be told the SSID separately (`promptForSsidAndPassword`); every\n   other peer derives the SSID from the password (`getSsidAndKey`), so only the password is\n   needed.\n2. **Fast-fail Apple↔Apple.** Selecting a macOS/iOS peer in hotspot mode is impossible (neither\n   can host), so macOS rejects it up front with the \"use Shared Network mode\" message instead of\n   failing deep in the join.\n\nBoth platforms hide/skip peer OS exactly when it can't matter: it's irrelevant in **shared\nnetwork mode** (discovery + receiver-anchor fixes the roles) and is learned automatically over\nthe BLE **OS characteristic** when **Bluetooth** is on. macOS therefore hides `peerSwitch` in\nshared network mode and when BT is active. **iOS has no peer switch at all**: it never hosts,\nand it gets the Android SSID (and password) out-of-band by scanning the host's `ssid;password`\nQR (`codeScanned`) or over Bluetooth — so it never needs the user to name the peer OS. macOS\nkeeps the switch only because it has no camera scanner and so needs the manual \"peer is Android\n⇒ ask for SSID\" path plus the early Apple↔Apple rejection.\n\nImplication if simplifying: macOS's 5-segment `peerSwitch` is really a binary \"is the peer\nAndroid?\" plus an Apple↔Apple guard. It could be reduced to that (or dropped if macOS gained QR\nscanning / always prompted for SSID), but it's kept 5-way to mirror the desktop UI. Don't remove\nit without replacing those two behaviors.\n\n## Conventions & gotchas\n\n- Storyboards: `iOS/FlyingCarpet/Base.lproj/Main.storyboard`,\n  `macOS/FlyingCarpet/Base.lproj/Main.storyboard`. macOS `peerSwitch` is an\n  `NSSegmentedControl`: Android(0), Linux(1), Windows(2), macOS(3), iOS(4) — see \"Peer OS\n  selection on Apple\" above for why it exists. iOS has no peer switch (it uses Bluetooth or the\n  QR scanner).\n- Duplicate-delivery guards: BLE and PHPicker can deliver twice; password/transfer starts are\n  guarded by `transfer.task == nil` on the main queue. Don't remove these.\n- `AtomicBool` test-and-set must be atomic under its lock (`testAndSet()`), not a `guard` then a\n  separate assignment — that's a TOCTOU race.\n- `.gitignore` here covers the Xcode-local noise (`xcuserdata`, `xcshareddata`, `swiftpm`,\n  `xcdebugger`, `.DS_Store`). Keep it — those directories embed local usernames and window state.\n\n## History\n\nThis code was developed in a separate `FlyingCarpetApple` repository and copied here without its\ngit history when v10 was released, so `git log` on these files starts at the import commit. The\niOS build submitted to the App Store as **10.0.0 (build 1)** is tagged `ios-10.0.0` in that old\nrepo. `../docs/apple-receive-throughput.md` is the one investigation doc that came across with\nit.\n","ARCHITECTURE.md":"# FlyingCarpet Connection Architecture\n\n## Overview\n\nA transfer involves four independent role axes. Each device takes one role on each axis. The roles are determined by a combination of **which device is sending/receiving** and **the peer's operating system**.\n\n| Axis | Role A | Role B |\n|------|--------|--------|\n| **Transfer direction** | Sender | Receiver |\n| **BLE** | Peripheral (advertiser) | Central (scanner) |\n| **WiFi hotspot** | Host | Joiner |\n| **TCP** | Server (listener) | Client (connector) |\n\nThese four axes are **not all aligned** — the mapping depends on the platform pair and connection mode.\n\n---\n\n## Bluetooth Roles\n\nBLE role is determined **solely by transfer direction**, the same on every platform:\n\n| Transfer Direction | BLE Role |\n|-------------------|----------|\n| **Sender** | **Peripheral** — advertises GATT service, exposes characteristics |\n| **Receiver** | **Central** — scans for peripheral, connects, reads/writes characteristics |\n\nSource:\n- Linux: `core/src/linux/bluetooth.rs` — `Mode::Send` → peripheral branch (line 85), `else` → central branch (line 156)\n- Windows: `core/src/windows/bluetooth.rs` — `Mode::Send` → peripheral branch (line 74), `else` → central branch (line 144)\n- Android: `MainActivity.kt` — `Mode.Sending` → `advertise()`, `Mode.Receiving` → `scan()` (lines 83-88)\n\n### BLE Data Flow\n\nThe GATT service exposes three characteristics: **OS**, **SSID**, **Password**.\n\nData flow direction depends on who is **hosting the hotspot**, not who is the BLE peripheral/central:\n\n| Hosting device is... | SSID/Password flow |\n|----------------------|-------------------|\n| **Peripheral** (sender is hosting) | Peripheral populates GATT; central reads |\n| **Central** (receiver is hosting) | Central writes SSID/password to peripheral's GATT |\n\nThe OS characteristic always flows both ways: each side needs to know the other's OS.\n\n---\n\n## Hotspot Hosting\n\nThe **hotspot host generates the WiFi password**, creates the hotspot, and acts as **TCP server**. The **joiner** connects to the hotspot and acts as **TCP client**.\n\n### Linux `is_hosting(peer, mode)`\n\n| Peer OS | Linux Hosts? |\n|---------|-------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Windows | **Never** |\n| Linux   | **Only if Receiving** |\n\nSource: `core/src/linux/network.rs` lines 11-20\n\n### Windows `is_hosting(peer, mode)`\n\n| Peer OS | Windows Hosts? |\n|---------|---------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Linux   | **Always** |\n| Windows | **Only if Receiving** |\n\nSource: `core/src/windows/network.rs` lines 674-683\n\n### Android `isHosting()`\n\n| Peer OS | Android Hosts? |\n|---------|---------------|\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Android | **Only if Receiving** |\n| Linux   | **Never** (Linux hosts) |\n| Windows | **Never** (Windows hosts) |\n\nSource: `MainViewModel.kt` lines 112-116\n\n### iOS / macOS\n\niOS and macOS **never host hotspots** — they lack a public hotspot API. The peer always hosts.\n\n---\n\n## Peer OS Selection — why it exists, and when it's hidden\n\nThe \"Select Peer OS\" control (desktop radio buttons, Android spinner, macOS `peerSwitch`) exists to answer one question the app can't otherwise answer before a connection exists: **who hosts the hotspot?** `is_hosting(peer, mode)` is a pure function of *this device's OS*, *the peer's OS*, and *the transfer direction* (see the tables above). This device knows its own OS and direction; the missing input is the **peer's OS**.\n\nThere are three ways to learn the peer's OS, and the UI control is only the third:\n\n1. **Shared network mode** — nobody hosts, so `is_hosting` is never called. Roles are fixed by direction (receiver = TCP server + password generator) and the peer is found by discovery over IP. Peer OS is irrelevant. → control hidden.\n2. **Hotspot mode with Bluetooth** — the BLE GATT **OS characteristic** carries each side's OS automatically (it always flows both ways, see BLE Data Flow). Peer OS is learned over the air. → control hidden.\n3. **Hotspot mode without Bluetooth** — there is no channel to exchange OS before the hotspot exists (chicken-and-egg), so the user supplies it. → **control shown.** This is the *only* case where it's needed.\n\nEvery platform gates the control on exactly this condition. The desktop's `checkStatus()` hides `peerBox` when `connectionMode === 'shared_network' || usingBluetooth`; macOS hides `peerSwitch` in the same two cases; iOS omits the control entirely (see below).\n\nBeyond the hosting decision, knowing the peer OS in hotspot-without-BT also drives two secondary behaviors:\n\n- **Android's SSID is OS-assigned**, not derivable from the password, so an Android *peer* means the joiner must be told the SSID separately (every other peer derives the SSID from the password). This is why the desktop/macOS prompt for SSID **only** when the peer is Android.\n- **Fast-fail on impossible pairs.** Selecting a macOS/iOS peer in hotspot mode is invalid (neither Apple device can host), so the app can reject it up front with \"use Shared Network mode\" instead of failing deep in the join.\n\n### Could it be removed?\n\nNot on host-capable platforms (Windows/Linux/Android) in hotspot-without-BT: the hosting decision genuinely needs the peer OS, and there's no earlier channel to negotiate it. The control *is* effectively the manual stand-in for the BLE OS exchange. The realistic ways to shrink it are to lean harder on the two automatic paths (Bluetooth or shared network) rather than to drop the manual fallback. On Apple platforms the picture is different — see `Apple/CLAUDE.md`, since Apple never hosts and so never needs the peer OS to decide hosting at all.\n\n---\n\n## TCP Roles (Hotspot Mode)\n\nTCP role follows directly from hotspot role:\n\n| Hotspot Role | TCP Role |\n|-------------|----------|\n| **Host** | **Server** — binds `0.0.0.0:3290`, calls `accept()` |\n| **Joiner** | **Client** — connects to host's gateway IP on port 3290 |\n\nSource:\n- Rust: `core/src/lib.rs` `start_tcp()` — `PeerResource::WifiClient` → connect, otherwise → bind+accept\n- Android: `MainViewModel.kt` `startTCP()` — `isHosting()` → `ServerSocket(3290).accept()`, else → `Socket(peerIP, 3290)`\n\n---\n\n## Password Generation & Sharing\n\n**The hotspot host generates the password.** It is shared with the peer via one of:\n\n1. **Bluetooth** — delivered through GATT characteristics (direction depends on who's hosting, see BLE Data Flow above)\n2. **QR code** — host displays QR code, joiner scans it (for mobile peers)\n3. **Manual entry** — host displays password as text, joiner types it in\n\nThe desktop app's `needPassword()` function:\n- Returns **false** → this device is **hosting** → generates and displays the password\n- Returns **true** → this device is **joining** → user must enter the host's password\n\n---\n\n## Complete Platform Pair Matrix (Hotspot Mode)\n\n\"A → B\" means A is sending to B.\n\n| Scenario | BLE: Peripheral | BLE: Central | Hotspot Host | TCP Server | Password Generator |\n|----------|----------------|-------------|-------------|------------|-------------------|\n| Linux → Android | Linux | Android | Linux | Linux | Linux |\n| Android → Linux | Android | Linux | Linux | Linux | Linux |\n| Linux → Windows | Linux | Windows | Windows | Windows | Windows |\n| Windows → Linux | Windows | Linux | Windows | Windows | Windows |\n| Linux → iOS/macOS | Linux | iOS/macOS | Linux | Linux | Linux |\n| Windows → iOS/macOS | Windows | iOS/macOS | Windows | Windows | Windows |\n| Android → iOS/macOS | Android | iOS/macOS | Android | Android | Android |\n| Linux → Linux | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Windows | Sender | Receiver | Receiver | Receiver | Receiver |\n| Android → Android | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Android | Windows | Android | Windows | Windows | Windows |\n| Android → Windows | Android | Windows | Windows | Windows | Windows |\n\n**Key pattern: Hotspot Host = TCP Server = Password Generator.** BLE Peripheral = Sender, BLE Central = Receiver. These two groupings are independent.\n\nIn shared network mode, there is no hotspot host. The **Receiver** takes over as TCP Server and Password Generator — consistent with the same-platform hotspot convention. See the Shared Network Mode section below.\n\n---\n\n## Shared Network Mode\n\nNo hotspot is created. Both devices are already on the same LAN.\n\n### Discovery\n\nBoth devices simultaneously:\n1. Send HMAC-signed announcements via UDP multicast (`239.255.73.67:3290`) and unicast subnet scan\n2. Listen for announcements from the peer\n3. Validate: magic bytes, HMAC (using password-derived key), timestamp window, opposite role\n\n### TCP in Shared Network Mode\n\n| Transfer Direction | TCP Role |\n|-------------------|----------|\n| **Receiver** | **Server** — binds TCP listener on port 3290 *before* discovery starts |\n| **Sender** | **Client** — connects to receiver's IP after discovery completes |\n\nThis is consistent with same-platform hotspot mode, where the receiver always hosts and is the TCP server.\n\n### Password in Shared Network Mode (without Bluetooth)\n\n- **Receiver** generates the password and displays it\n- **Sender** enters the password manually\n\nConsistent with hotspot mode: the receiver is always the \"anchor\" role (host in hotspot, server + password generator in shared network).\n\nThe password is stretched with PBKDF2-HMAC-SHA256 into the Noise pre-shared key (PSK); the discovery HMAC key is derived from that PSK, and the same PSK authenticates the Noise handshake that encrypts the whole transfer with ChaCha20-Poly1305. There is no separate password-derived AES key — v10 removed the old inner per-chunk AES and Noise is the sole cipher. See `docs/shared-network-crypto.md`.\n\n### Bluetooth + Shared Network Mode\n\nThe current BLE protocol is tightly coupled to the hotspot flow — it exchanges peer OS, SSID, and password, and uses `is_hosting()` to determine BLE data flow direction. In shared network mode, peer OS and SSID are irrelevant, and `is_hosting()` doesn't apply.\n\n**Decision: Bluetooth is hotspot-only. Shared network mode exchanges the password manually (receiver displays it; sender types it or scans a QR code). This is deliberate, not a missing feature — do not re-add BLE to shared network mode.**\n\nRationale. The only reason to want BLE in shared mode is to spare users from typing the password, and the users who most need that are **Apple-to-Apple** pairs: Apple devices cannot host a hotspot (no public API), so they are *forced* into shared network mode and always type. But two Apple devices cannot complete the BLE exchange at all. Apple does not support Bluetooth pairing between an iPhone and a Mac — by design; Continuity/AirDrop use BLE only for discovery and move data over Wi-Fi/AWDL, never forming a classic bond — and Flying Carpet's GATT characteristics all require an encrypted, bonded link (`.readEncryptionRequired` / `.writeEncryptionRequired`). No bond is possible, so the encryption-required characteristics can never be read: the exchange fails for exactly the pairing that motivates it.\n\nEvery *other* pairing that could do BLE (non-Apple↔non-Apple, or Apple↔non-Apple) can also almost always use **hotspot mode** instead, where BLE already works. So the residual benefit of shared-mode BLE is a thin slice of transfers, bought at the cost of a new GATT flow across three codebases (Rust core, Android, Apple) plus manual-entry fallbacks. The Apple repo (`FlyingCarpetApple`) actually implemented shared-mode BLE in commit `4a6b889` and then removed it in `b7e9b59` for these reasons; this note records that conclusion for the desktop/Android side so it isn't rediscovered. If reducing password typing ever becomes a priority, improve the out-of-band password UX (QR) that works on every platform pair — not BLE.\n","CLAUDE.md":"# Flying Carpet\n\nEncrypted, no-internet file transfer between **Android, iOS, Linux, macOS, and Windows**, over either an ad-hoc Wi-Fi hotspot (one device hosts) or a **shared network** both devices are already on. Two devices, Wi-Fi (or ethernet), optionally Bluetooth. Port **3290** throughout.\n\n## Three implementations, one wire protocol (critical)\n\nAll five platforms live in this repo, in **three independent implementations of the same wire protocol**. A change to the on-the-wire format in one is a breaking change unless mirrored in the other two:\n\n- **Rust** (`core/`) — the **reference implementation** the other two are tested against. Ships the Tauri desktop app (`Flying Carpet/`) for Windows + Linux.\n- **Kotlin** (`Android/FlyingCarpet/`) — Android.\n- **Swift** (`Apple/`) — iOS + macOS. A full port, **not** a binding over the Rust core. Build only on a Mac; see `Apple/CLAUDE.md`.\n\nWhen touching the protocol (discovery bytes, version/mode preamble, Noise handshake, framing), update **all three implementations** and their cross-platform known-answer tests together.\n\n### Where code lives vs. where binaries ship (don't conflate these)\n\n- **The Rust code builds for Windows and Linux only.** `core/src/` has just `windows/` and `linux/`; `lib.rs` cfg-selects `network`/`bluetooth` on those two `target_os` values, and there is no `target_os = \"macos\"` anywhere in `core/`. A Tauri/`wry`/`webkit2gtk` change therefore affects **two** desktop platforms, not three — macOS is served by the Swift app in `Apple/`, which shares no code with `core/`.\n- **The macOS binary is released from this repo's Releases page** as a **`.zip` of the `.app`** (`macOS_FlyingCarpet_<version>.zip`) — *not* a `.dmg`. The `.dmg` format ended at v8.0.1; v9.0.0 switched to `.zip`. Don't infer from a macOS binary existing that the Rust code targets macOS — it's built from `Apple/macOS/`. There is also a Homebrew **cask** (`brew install flying-carpet`), which is maintained outside this repo.\n- `tauri.conf.json` still lists `icons/icon.icns` — genuinely stale, intentionally left alone. Not evidence of macOS support either.\n- The Swift code was developed in a separate `FlyingCarpetApple` repo and imported without history at the v10 release, so `git log` on `Apple/` starts at the import commit. Commit hashes cited for Swift changes in the older docs (e.g. `4c59af6` in `docs/bluetooth-field-guide.md`, `4a6b889`/`b7e9b59` in `ARCHITECTURE.md`) belong to that repo and won't resolve here.\n\n## Layout\n\n- `core/` — Rust core crate `flying-carpet-core` (v10). Platform-split: `core/src/{windows,linux}/` for network/bluetooth/peripheral/central; the `bluetooth` module is `cfg`-selected per-OS in `lib.rs`. Key files: `lib.rs` (`start_transfer` entry point), `discovery.rs`, `noise.rs`, `sending.rs`/`receiving.rs`.\n- `Flying Carpet/` — Tauri desktop app. Rust backend in `Flying Carpet/src-tauri/` (workspace member), JS/HTML frontend in `Flying Carpet/src/` (`main.js`, `index.html`). **Note the space in the directory name** — quote it in shell commands.\n- `Android/FlyingCarpet/` — Android app (Kotlin). Noise/discovery ports in `app/src/main/java/dev/spiegl/flyingcarpet/`.\n- `Apple/` — iOS + macOS apps (Swift). Protocol code shared by both in `Apple/shared/`, per-platform UI in `Apple/iOS/` and `Apple/macOS/`. **Read `Apple/CLAUDE.md` before changing anything under it** — the Apple platforms have constraints the others don't (no programmatic hotspot, so Apple-to-Apple requires shared network mode; BLE can't pair iPhone↔Mac).\n- `docs/` — design docs (see below). `ARCHITECTURE.md` — connection role model.\n\n## Build & test\n\nRust is a Cargo **workspace** (`core` + `Flying Carpet/src-tauri`):\n\n- `cargo test` — run all Rust tests (includes the Noise/discovery known-answer vectors in `core/src/noise.rs` and `core/src/discovery.rs`). `cargo build` to compile.\n- Desktop app: `cargo tauri dev` (run) / `cargo tauri build` (release). Needs the Tauri CLI and the Linux deps listed in `README.md`.\n- Android: from `Android/FlyingCarpet/`, `./gradlew assembleDebug` / `./gradlew test`. **Set `JAVA_HOME` to the Android Studio JBR** (the bundled JDK) or Gradle fails.\n- Apple: **macOS only**, and outside the Cargo workspace — `cargo build` never touches it. From `Apple/`, `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build`. `DEVELOPMENT_TEAM` is blank in both projects, so signing fails until you set your team in Xcode or pass `DEVELOPMENT_TEAM=YOURTEAMID`. Details in `Apple/CLAUDE.md`.\n\n## Architecture & design docs — read before changing these areas\n\n- **`ARCHITECTURE.md`** — the four independent role axes (transfer direction / BLE peripheral-central / hotspot host-joiner / TCP server-client) and how they map per platform pair. Read before touching connection setup, hosting logic, or BLE.\n- **`docs/shared-network-crypto.md`** — the full v10 cryptographic design (the normative reference for the handshake bytes). Read before touching anything crypto, discovery-auth, or the record/framing layer.\n- **`docs/bluetooth-field-guide.md`** — **read before touching any BLE code on any platform.** The four independent axes (advertising / scanning / bonding / GATT services), seven hard-won laws, a per-platform matrix, and a symptom→cause playbook. Bluetooth bugs here are subtle, intermittent, and platform-asymmetric; several have been re-derived from scratch more than once. Chronological investigation logs: `docs/windows-ble-gatt-0x8000ffff.md`, `docs/ble-bond-asymmetries.md`.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\n- **v10 = Noise.** Every transfer (both modes) runs a `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake; the PSK is `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is the **sole** cipher — the old inner per-chunk AES is gone. v10 is a clean break; v9 peers are rejected. If v10 ships before a later Noise wire change, that change must bump to v11.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then every preamble byte is bound into the Noise prologue. Both platforms of any pair must build the prologue identically (`build_prologue`/`buildPrologue`). Cross-platform KATs guard this — keep all three in sync (Rust `core/src/noise.rs`, Kotlin `NoiseUnitTest`, Swift `Apple/macOS/FlyingCarpetTests/FlyingCarpetTests.swift`; discovery vector: `core/src/discovery.rs` `test_cross_platform_vector` == Android `DiscoveryUnitTest.kt` == the Swift discovery test in the same file). Note the Swift KATs run only under `xcodebuild` on a Mac, so `cargo test` and `./gradlew test` passing is **not** evidence the Swift side still agrees.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer and displays it; never reuse, never user-chosen, never \"remember.\" The entire \"offline crack is worthless\" security argument depends on this (see the crypto doc §7). The discovery HMAC key is derived from the stretched PSK, so no fast hash of the password goes on the wire.\n- **Bluetooth is hotspot-only.** Shared network mode exchanges the password manually (display + type/QR); do **not** re-add BLE to shared mode. Apple-to-Apple can't pair iPhone↔Mac over BLE by design, which is exactly the pair that would need it. Rationale is recorded in `ARCHITECTURE.md` (\"Bluetooth + Shared Network Mode\"). Every platform greys out its BT switch in shared network mode; on Apple that goes through `bluetoothSwitchShouldBeEnabled()`, since restoring the switch on hardware capability alone re-enabled it after a transfer.\n- **Receiver is the anchor.** In both modes the receiver generates the password and is the TCP server (Noise responder); the sender is the TCP client (Noise initiator).\n\n## Conventions & gotchas\n\n- Android: keep `res/layout/` and `res/layout-land/` in sync when changing the UI.\n- `core/Cargo.toml` is pinned to **LF** line endings (`.gitattributes`); don't let an editor rewrite it to CRLF.\n- Header-value bounds (file count / filename length / chunk size sanity checks) and filename sanitization apply to values read from the **Noise-decrypted** stream, not the raw socket.\n"},"files":{"Apple/CLAUDE.md":"# Flying Carpet — Apple (iOS + macOS)\n\nThe **Swift** iOS and macOS apps. Port **3290** throughout, same wire protocol as the Rust core\nand the Android app. See the root `CLAUDE.md` for the repo-wide picture and\n`../ARCHITECTURE.md` for the connection role model.\n\n**Build only on a Mac.** Nothing here compiles as part of the Cargo workspace, and nothing in\n`core/` targets Apple platforms — the Swift code is a full independent port of the protocol, not\na binding over the Rust core. The **Rust core is the reference** this port is tested against.\n\n## Layout\n\n- `shared/` — protocol code shared by both apps (added to both Xcode targets):\n  - `Transfer.swift` — the orchestrator. `runTransfer()` entry point, connection-mode logic,\n    password generation, `Transfer.Delegate` protocol (the per-platform ViewController\n    implements it).\n  - `Discovery.swift` — shared network peer discovery: multicast + unicast UDP on port 3290\n    over raw POSIX sockets, then TCP on 3290.\n  - `Network.swift` — TCP read/write in a `TCPConnectionProtocol` extension; `TCPClient`\n    (hotspot) and `TCPConnectionWrapper` (shared). `AtomicBool` is defined here once.\n    Connections run on `networkQueue`, not main.\n  - `Noise.swift` — `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake + `buildPrologue`.\n  - `Send.swift` / `Receive.swift` — sending/receiving halves.\n  - `Bluetooth.swift` — BLE password exchange (**hotspot mode only**, see invariants).\n- `iOS/FlyingCarpet/` — iOS app. `ViewController.swift` (main UI + BLE + picker delegates),\n  `QRScanner.swift` / `QRScanner.storyboard` (camera QR scanning), `Base.lproj/Main.storyboard`.\n- `macOS/FlyingCarpet/` — macOS app. `ViewController.swift`, `SsidBox.swift`,\n  `Base.lproj/Main.storyboard`.\n\n## Build\n\n```\nDEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \\\n  xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build\n```\n\nFor a specific destination: iOS `-destination 'generic/platform=iOS Simulator'`, macOS\n`-destination 'platform=macOS'`.\n\n**Code signing:** `DEVELOPMENT_TEAM` is deliberately blank in both `project.pbxproj` files, so a\nfresh clone fails with *\"Signing for 'FlyingCarpet' requires a development team.\"* Either select\nyour team once in Xcode (target → Signing & Capabilities → Team; Xcode writes it back into\n`project.pbxproj` as a local modification) or pass it per-build without touching the file:\n\n```\nxcodebuild ... DEVELOPMENT_TEAM=YOURTEAMID -allowProvisioningUpdates build\n```\n\n**The protocol tests live in the macOS target.** `macOS/FlyingCarpetTests/FlyingCarpetTests.swift`\nholds 25 of them, including the cross-platform Noise and discovery known-answer vectors. Run that\ntarget when changing protocol code — the KATs are not duplicated in the iOS target.\n\n`iOS/FlyingCarpetTests/FlyingCarpetTests.swift` holds the 8 tests that can only be exercised on\niOS: the bounded Noise receive buffer (#142) on the platform whose receiver actually failed, and\nthe QR display layout (#141), which is UIKit and has no macOS counterpart. Run it against a\nsimulator:\n\n```\nxcodebuild -project iOS/FlyingCarpet.xcodeproj -scheme FlyingCarpet \\\n  -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \\\n  -only-testing:FlyingCarpetTests test\n```\n\nTwo things to know before running either:\n\n- **`-only-testing:FlyingCarpetTests`.** Without it the scheme also runs `FlyingCarpetUITests`,\n  which is untouched Xcode boilerplate that asserts nothing. On macOS it builds and launches a\n  separate `FlyingCarpetUITests-Runner.app`, which prompts for automation permission and re-prompts\n  on every rebuild, because an ad-hoc-signed bundle gets a new signature each time.\n- **A team on the app target alone breaks the macOS test bundle.** Selecting your team in Xcode\n  writes `DEVELOPMENT_TEAM` into the app target's configs only, and the test bundle then fails to\n  load into its host with *\"mapping process and mapped file (non-platform) have different Team\n  IDs\"*. Pass `DEVELOPMENT_TEAM=YOURTEAMID` to `xcodebuild` so every target agrees. Simulator runs\n  are unaffected.\n\n**SourceKit lies about single files.** Diagnostics that lint one file in isolation report bogus\n\"Cannot find type 'Transfer'/'Bluetooth' in scope\" and \"No such module 'UIKit'\" errors — the\ncross-file and cross-target types resolve fine in a real `xcodebuild`. Trust the build, not the\nisolated-file lint.\n\n## Architecture\n\n- **Two connection modes:** `hotspot` (one device hosts an ad-hoc Wi-Fi network) and\n  `sharedNetwork` (both already on the same LAN; discovery finds the peer over IP). Set from\n  `connectionModeSwitch` (Hotspot=0 / Shared Network=1) on both platforms.\n- **Receiver is the anchor.** In both modes the receiver generates the password, is the TCP\n  server (Noise responder), and (shared network) displays the password; the sender is the TCP\n  client (Noise initiator) and supplies the password.\n- **Apple-to-Apple only works in shared network mode.** Apple no longer lets hotspots be\n  configured programmatically, so hotspot mode is blocked for two Apple devices (error\n  `appleToAppleHotspotErrorMessage`). Use a normal Wi-Fi network or a manually-configured\n  Personal Hotspot the other device has joined.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\nThese mirror the root `CLAUDE.md`; the Swift-specific details are here.\n\n- **v10 = Noise.** Every transfer (both modes) runs the Noise NNpsk0 handshake; the PSK is\n  `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is\n  the sole cipher. v9 peers are rejected.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then\n  every preamble byte is bound into the Noise prologue. Both platforms of any pair must build\n  the prologue identically. Cross-platform KATs guard this — keep the Swift Noise tests\n  (`macOS/FlyingCarpetTests/FlyingCarpetTests.swift`) in sync with `../core/src/noise.rs` and the\n  Kotlin `NoiseUnitTest`; discovery vector in sync with `../core/src/discovery.rs`'s\n  `test_cross_platform_vector`.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer\n  (`generatePassword()` — 10 chars from a 57-symbol confusables-free charset, matching the\n  desktop/Android generators) and displays it; never reuse, never user-chosen, never remember.\n- **Bluetooth is hotspot-only.** Shared network exchanges the password manually (display +\n  type/QR); do **not** re-add BLE to shared mode. The BT switch is greyed out in shared network\n  mode on both platforms — every site that re-enables it goes through\n  `bluetoothSwitchShouldBeEnabled()` (`capable && connectionMode == .hotspot`), because\n  `toggleUI` and `toggleBluetoothUI` both used to restore it on hardware capability alone.\n  Apple-to-Apple can't pair iPhone↔Mac over BLE by design — exactly the pair that would need it.\n\n## Password exchange & QR codes (shared network mode)\n\nThe receiver generates and displays the password; the sender supplies it. To avoid typing, the\nreceiver can display it as a **QR code** and the sender can **scan** it.\n\n- **QR content format (must match across platforms):** shared network QR = the **bare\n  password**. Hotspot QR = `\"ssid;password\"`. Scanners split on `;`: >1 component ⇒ hotspot\n  (ssid + password), otherwise the bare password.\n- **Receivers display the QR** two ways (like Android/desktop): in the main-screen logo image\n  view (`logoImageView`, reset to the app logo in `toggleUI(transferRunning: false)`), and in a\n  popup. macOS uses CoreImage `CIQRCodeGenerator` (`qrCodeImage(from:size:)` → `NSImage`) in the\n  password `NSAlert`'s accessory view; iOS uses the same generator (→ `UIImage`) in a dedicated\n  `QRDisplayViewController` (a `UIAlertController` can't host an image). Both also show the text\n  password for a sender that can't scan.\n- **iOS sender** offers \"Scan QR Code\" in the password prompt (`promptForPassword`), routing to\n  `ScannerViewController` via the `goToQRScanner` segue; the scanned string flows through\n  `codeScanned(result:)`, which sets the password and starts the transfer. iOS can also still\n  type the password.\n- **macOS has no scanner** (Macs aren't portable and may lack a camera). So a macOS *sender*\n  types the password shown on the peer; iOS/Android senders can scan. Net: iOS↔macOS uses QR\n  when iOS is the sender; macOS-as-sender types.\n\n## Peer OS selection on Apple (why macOS has a `peerSwitch` and iOS doesn't)\n\nOn host-capable platforms the \"Select Peer OS\" control feeds `is_hosting(peer, mode)` — who\ncreates the hotspot (see `../ARCHITECTURE.md`, \"Peer OS Selection\"). **Apple devices never host\na hotspot** (no public API — the peer always hosts), so on Apple the peer's OS is *never* needed\nto decide hosting. That collapses the control's job to two leftovers, both **hotspot-mode-\nwithout-Bluetooth only**:\n\n1. **Is the peer Android?** An Android hotspot's SSID is OS-assigned and not derivable from the\n   password, so the joiner must be told the SSID separately (`promptForSsidAndPassword`); every\n   other peer derives the SSID from the password (`getSsidAndKey`), so only the password is\n   needed.\n2. **Fast-fail Apple↔Apple.** Selecting a macOS/iOS peer in hotspot mode is impossible (neither\n   can host), so macOS rejects it up front with the \"use Shared Network mode\" message instead of\n   failing deep in the join.\n\nBoth platforms hide/skip peer OS exactly when it can't matter: it's irrelevant in **shared\nnetwork mode** (discovery + receiver-anchor fixes the roles) and is learned automatically over\nthe BLE **OS characteristic** when **Bluetooth** is on. macOS therefore hides `peerSwitch` in\nshared network mode and when BT is active. **iOS has no peer switch at all**: it never hosts,\nand it gets the Android SSID (and password) out-of-band by scanning the host's `ssid;password`\nQR (`codeScanned`) or over Bluetooth — so it never needs the user to name the peer OS. macOS\nkeeps the switch only because it has no camera scanner and so needs the manual \"peer is Android\n⇒ ask for SSID\" path plus the early Apple↔Apple rejection.\n\nImplication if simplifying: macOS's 5-segment `peerSwitch` is really a binary \"is the peer\nAndroid?\" plus an Apple↔Apple guard. It could be reduced to that (or dropped if macOS gained QR\nscanning / always prompted for SSID), but it's kept 5-way to mirror the desktop UI. Don't remove\nit without replacing those two behaviors.\n\n## Conventions & gotchas\n\n- Storyboards: `iOS/FlyingCarpet/Base.lproj/Main.storyboard`,\n  `macOS/FlyingCarpet/Base.lproj/Main.storyboard`. macOS `peerSwitch` is an\n  `NSSegmentedControl`: Android(0), Linux(1), Windows(2), macOS(3), iOS(4) — see \"Peer OS\n  selection on Apple\" above for why it exists. iOS has no peer switch (it uses Bluetooth or the\n  QR scanner).\n- Duplicate-delivery guards: BLE and PHPicker can deliver twice; password/transfer starts are\n  guarded by `transfer.task == nil` on the main queue. Don't remove these.\n- `AtomicBool` test-and-set must be atomic under its lock (`testAndSet()`), not a `guard` then a\n  separate assignment — that's a TOCTOU race.\n- `.gitignore` here covers the Xcode-local noise (`xcuserdata`, `xcshareddata`, `swiftpm`,\n  `xcdebugger`, `.DS_Store`). Keep it — those directories embed local usernames and window state.\n\n## History\n\nThis code was developed in a separate `FlyingCarpetApple` repository and copied here without its\ngit history when v10 was released, so `git log` on these files starts at the import commit. The\niOS build submitted to the App Store as **10.0.0 (build 1)** is tagged `ios-10.0.0` in that old\nrepo. `../docs/apple-receive-throughput.md` is the one investigation doc that came across with\nit.\n","ARCHITECTURE.md":"# FlyingCarpet Connection Architecture\n\n## Overview\n\nA transfer involves four independent role axes. Each device takes one role on each axis. The roles are determined by a combination of **which device is sending/receiving** and **the peer's operating system**.\n\n| Axis | Role A | Role B |\n|------|--------|--------|\n| **Transfer direction** | Sender | Receiver |\n| **BLE** | Peripheral (advertiser) | Central (scanner) |\n| **WiFi hotspot** | Host | Joiner |\n| **TCP** | Server (listener) | Client (connector) |\n\nThese four axes are **not all aligned** — the mapping depends on the platform pair and connection mode.\n\n---\n\n## Bluetooth Roles\n\nBLE role is determined **solely by transfer direction**, the same on every platform:\n\n| Transfer Direction | BLE Role |\n|-------------------|----------|\n| **Sender** | **Peripheral** — advertises GATT service, exposes characteristics |\n| **Receiver** | **Central** — scans for peripheral, connects, reads/writes characteristics |\n\nSource:\n- Linux: `core/src/linux/bluetooth.rs` — `Mode::Send` → peripheral branch (line 85), `else` → central branch (line 156)\n- Windows: `core/src/windows/bluetooth.rs` — `Mode::Send` → peripheral branch (line 74), `else` → central branch (line 144)\n- Android: `MainActivity.kt` — `Mode.Sending` → `advertise()`, `Mode.Receiving` → `scan()` (lines 83-88)\n\n### BLE Data Flow\n\nThe GATT service exposes three characteristics: **OS**, **SSID**, **Password**.\n\nData flow direction depends on who is **hosting the hotspot**, not who is the BLE peripheral/central:\n\n| Hosting device is... | SSID/Password flow |\n|----------------------|-------------------|\n| **Peripheral** (sender is hosting) | Peripheral populates GATT; central reads |\n| **Central** (receiver is hosting) | Central writes SSID/password to peripheral's GATT |\n\nThe OS characteristic always flows both ways: each side needs to know the other's OS.\n\n---\n\n## Hotspot Hosting\n\nThe **hotspot host generates the WiFi password**, creates the hotspot, and acts as **TCP server**. The **joiner** connects to the hotspot and acts as **TCP client**.\n\n### Linux `is_hosting(peer, mode)`\n\n| Peer OS | Linux Hosts? |\n|---------|-------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Windows | **Never** |\n| Linux   | **Only if Receiving** |\n\nSource: `core/src/linux/network.rs` lines 11-20\n\n### Windows `is_hosting(peer, mode)`\n\n| Peer OS | Windows Hosts? |\n|---------|---------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Linux   | **Always** |\n| Windows | **Only if Receiving** |\n\nSource: `core/src/windows/network.rs` lines 674-683\n\n### Android `isHosting()`\n\n| Peer OS | Android Hosts? |\n|---------|---------------|\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Android | **Only if Receiving** |\n| Linux   | **Never** (Linux hosts) |\n| Windows | **Never** (Windows hosts) |\n\nSource: `MainViewModel.kt` lines 112-116\n\n### iOS / macOS\n\niOS and macOS **never host hotspots** — they lack a public hotspot API. The peer always hosts.\n\n---\n\n## Peer OS Selection — why it exists, and when it's hidden\n\nThe \"Select Peer OS\" control (desktop radio buttons, Android spinner, macOS `peerSwitch`) exists to answer one question the app can't otherwise answer before a connection exists: **who hosts the hotspot?** `is_hosting(peer, mode)` is a pure function of *this device's OS*, *the peer's OS*, and *the transfer direction* (see the tables above). This device knows its own OS and direction; the missing input is the **peer's OS**.\n\nThere are three ways to learn the peer's OS, and the UI control is only the third:\n\n1. **Shared network mode** — nobody hosts, so `is_hosting` is never called. Roles are fixed by direction (receiver = TCP server + password generator) and the peer is found by discovery over IP. Peer OS is irrelevant. → control hidden.\n2. **Hotspot mode with Bluetooth** — the BLE GATT **OS characteristic** carries each side's OS automatically (it always flows both ways, see BLE Data Flow). Peer OS is learned over the air. → control hidden.\n3. **Hotspot mode without Bluetooth** — there is no channel to exchange OS before the hotspot exists (chicken-and-egg), so the user supplies it. → **control shown.** This is the *only* case where it's needed.\n\nEvery platform gates the control on exactly this condition. The desktop's `checkStatus()` hides `peerBox` when `connectionMode === 'shared_network' || usingBluetooth`; macOS hides `peerSwitch` in the same two cases; iOS omits the control entirely (see below).\n\nBeyond the hosting decision, knowing the peer OS in hotspot-without-BT also drives two secondary behaviors:\n\n- **Android's SSID is OS-assigned**, not derivable from the password, so an Android *peer* means the joiner must be told the SSID separately (every other peer derives the SSID from the password). This is why the desktop/macOS prompt for SSID **only** when the peer is Android.\n- **Fast-fail on impossible pairs.** Selecting a macOS/iOS peer in hotspot mode is invalid (neither Apple device can host), so the app can reject it up front with \"use Shared Network mode\" instead of failing deep in the join.\n\n### Could it be removed?\n\nNot on host-capable platforms (Windows/Linux/Android) in hotspot-without-BT: the hosting decision genuinely needs the peer OS, and there's no earlier channel to negotiate it. The control *is* effectively the manual stand-in for the BLE OS exchange. The realistic ways to shrink it are to lean harder on the two automatic paths (Bluetooth or shared network) rather than to drop the manual fallback. On Apple platforms the picture is different — see `Apple/CLAUDE.md`, since Apple never hosts and so never needs the peer OS to decide hosting at all.\n\n---\n\n## TCP Roles (Hotspot Mode)\n\nTCP role follows directly from hotspot role:\n\n| Hotspot Role | TCP Role |\n|-------------|----------|\n| **Host** | **Server** — binds `0.0.0.0:3290`, calls `accept()` |\n| **Joiner** | **Client** — connects to host's gateway IP on port 3290 |\n\nSource:\n- Rust: `core/src/lib.rs` `start_tcp()` — `PeerResource::WifiClient` → connect, otherwise → bind+accept\n- Android: `MainViewModel.kt` `startTCP()` — `isHosting()` → `ServerSocket(3290).accept()`, else → `Socket(peerIP, 3290)`\n\n---\n\n## Password Generation & Sharing\n\n**The hotspot host generates the password.** It is shared with the peer via one of:\n\n1. **Bluetooth** — delivered through GATT characteristics (direction depends on who's hosting, see BLE Data Flow above)\n2. **QR code** — host displays QR code, joiner scans it (for mobile peers)\n3. **Manual entry** — host displays password as text, joiner types it in\n\nThe desktop app's `needPassword()` function:\n- Returns **false** → this device is **hosting** → generates and displays the password\n- Returns **true** → this device is **joining** → user must enter the host's password\n\n---\n\n## Complete Platform Pair Matrix (Hotspot Mode)\n\n\"A → B\" means A is sending to B.\n\n| Scenario | BLE: Peripheral | BLE: Central | Hotspot Host | TCP Server | Password Generator |\n|----------|----------------|-------------|-------------|------------|-------------------|\n| Linux → Android | Linux | Android | Linux | Linux | Linux |\n| Android → Linux | Android | Linux | Linux | Linux | Linux |\n| Linux → Windows | Linux | Windows | Windows | Windows | Windows |\n| Windows → Linux | Windows | Linux | Windows | Windows | Windows |\n| Linux → iOS/macOS | Linux | iOS/macOS | Linux | Linux | Linux |\n| Windows → iOS/macOS | Windows | iOS/macOS | Windows | Windows | Windows |\n| Android → iOS/macOS | Android | iOS/macOS | Android | Android | Android |\n| Linux → Linux | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Windows | Sender | Receiver | Receiver | Receiver | Receiver |\n| Android → Android | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Android | Windows | Android | Windows | Windows | Windows |\n| Android → Windows | Android | Windows | Windows | Windows | Windows |\n\n**Key pattern: Hotspot Host = TCP Server = Password Generator.** BLE Peripheral = Sender, BLE Central = Receiver. These two groupings are independent.\n\nIn shared network mode, there is no hotspot host. The **Receiver** takes over as TCP Server and Password Generator — consistent with the same-platform hotspot convention. See the Shared Network Mode section below.\n\n---\n\n## Shared Network Mode\n\nNo hotspot is created. Both devices are already on the same LAN.\n\n### Discovery\n\nBoth devices simultaneously:\n1. Send HMAC-signed announcements via UDP multicast (`239.255.73.67:3290`) and unicast subnet scan\n2. Listen for announcements from the peer\n3. Validate: magic bytes, HMAC (using password-derived key), timestamp window, opposite role\n\n### TCP in Shared Network Mode\n\n| Transfer Direction | TCP Role |\n|-------------------|----------|\n| **Receiver** | **Server** — binds TCP listener on port 3290 *before* discovery starts |\n| **Sender** | **Client** — connects to receiver's IP after discovery completes |\n\nThis is consistent with same-platform hotspot mode, where the receiver always hosts and is the TCP server.\n\n### Password in Shared Network Mode (without Bluetooth)\n\n- **Receiver** generates the password and displays it\n- **Sender** enters the password manually\n\nConsistent with hotspot mode: the receiver is always the \"anchor\" role (host in hotspot, server + password generator in shared network).\n\nThe password is stretched with PBKDF2-HMAC-SHA256 into the Noise pre-shared key (PSK); the discovery HMAC key is derived from that PSK, and the same PSK authenticates the Noise handshake that encrypts the whole transfer with ChaCha20-Poly1305. There is no separate password-derived AES key — v10 removed the old inner per-chunk AES and Noise is the sole cipher. See `docs/shared-network-crypto.md`.\n\n### Bluetooth + Shared Network Mode\n\nThe current BLE protocol is tightly coupled to the hotspot flow — it exchanges peer OS, SSID, and password, and uses `is_hosting()` to determine BLE data flow direction. In shared network mode, peer OS and SSID are irrelevant, and `is_hosting()` doesn't apply.\n\n**Decision: Bluetooth is hotspot-only. Shared network mode exchanges the password manually (receiver displays it; sender types it or scans a QR code). This is deliberate, not a missing feature — do not re-add BLE to shared network mode.**\n\nRationale. The only reason to want BLE in shared mode is to spare users from typing the password, and the users who most need that are **Apple-to-Apple** pairs: Apple devices cannot host a hotspot (no public API), so they are *forced* into shared network mode and always type. But two Apple devices cannot complete the BLE exchange at all. Apple does not support Bluetooth pairing between an iPhone and a Mac — by design; Continuity/AirDrop use BLE only for discovery and move data over Wi-Fi/AWDL, never forming a classic bond — and Flying Carpet's GATT characteristics all require an encrypted, bonded link (`.readEncryptionRequired` / `.writeEncryptionRequired`). No bond is possible, so the encryption-required characteristics can never be read: the exchange fails for exactly the pairing that motivates it.\n\nEvery *other* pairing that could do BLE (non-Apple↔non-Apple, or Apple↔non-Apple) can also almost always use **hotspot mode** instead, where BLE already works. So the residual benefit of shared-mode BLE is a thin slice of transfers, bought at the cost of a new GATT flow across three codebases (Rust core, Android, Apple) plus manual-entry fallbacks. The Apple repo (`FlyingCarpetApple`) actually implemented shared-mode BLE in commit `4a6b889` and then removed it in `b7e9b59` for these reasons; this note records that conclusion for the desktop/Android side so it isn't rediscovered. If reducing password typing ever becomes a priority, improve the out-of-band password UX (QR) that works on every platform pair — not BLE.\n","CLAUDE.md":"# Flying Carpet\n\nEncrypted, no-internet file transfer between **Android, iOS, Linux, macOS, and Windows**, over either an ad-hoc Wi-Fi hotspot (one device hosts) or a **shared network** both devices are already on. Two devices, Wi-Fi (or ethernet), optionally Bluetooth. Port **3290** throughout.\n\n## Three implementations, one wire protocol (critical)\n\nAll five platforms live in this repo, in **three independent implementations of the same wire protocol**. A change to the on-the-wire format in one is a breaking change unless mirrored in the other two:\n\n- **Rust** (`core/`) — the **reference implementation** the other two are tested against. Ships the Tauri desktop app (`Flying Carpet/`) for Windows + Linux.\n- **Kotlin** (`Android/FlyingCarpet/`) — Android.\n- **Swift** (`Apple/`) — iOS + macOS. A full port, **not** a binding over the Rust core. Build only on a Mac; see `Apple/CLAUDE.md`.\n\nWhen touching the protocol (discovery bytes, version/mode preamble, Noise handshake, framing), update **all three implementations** and their cross-platform known-answer tests together.\n\n### Where code lives vs. where binaries ship (don't conflate these)\n\n- **The Rust code builds for Windows and Linux only.** `core/src/` has just `windows/` and `linux/`; `lib.rs` cfg-selects `network`/`bluetooth` on those two `target_os` values, and there is no `target_os = \"macos\"` anywhere in `core/`. A Tauri/`wry`/`webkit2gtk` change therefore affects **two** desktop platforms, not three — macOS is served by the Swift app in `Apple/`, which shares no code with `core/`.\n- **The macOS binary is released from this repo's Releases page** as a **`.zip` of the `.app`** (`macOS_FlyingCarpet_<version>.zip`) — *not* a `.dmg`. The `.dmg` format ended at v8.0.1; v9.0.0 switched to `.zip`. Don't infer from a macOS binary existing that the Rust code targets macOS — it's built from `Apple/macOS/`. There is also a Homebrew **cask** (`brew install flying-carpet`), which is maintained outside this repo.\n- `tauri.conf.json` still lists `icons/icon.icns` — genuinely stale, intentionally left alone. Not evidence of macOS support either.\n- The Swift code was developed in a separate `FlyingCarpetApple` repo and imported without history at the v10 release, so `git log` on `Apple/` starts at the import commit. Commit hashes cited for Swift changes in the older docs (e.g. `4c59af6` in `docs/bluetooth-field-guide.md`, `4a6b889`/`b7e9b59` in `ARCHITECTURE.md`) belong to that repo and won't resolve here.\n\n## Layout\n\n- `core/` — Rust core crate `flying-carpet-core` (v10). Platform-split: `core/src/{windows,linux}/` for network/bluetooth/peripheral/central; the `bluetooth` module is `cfg`-selected per-OS in `lib.rs`. Key files: `lib.rs` (`start_transfer` entry point), `discovery.rs`, `noise.rs`, `sending.rs`/`receiving.rs`.\n- `Flying Carpet/` — Tauri desktop app. Rust backend in `Flying Carpet/src-tauri/` (workspace member), JS/HTML frontend in `Flying Carpet/src/` (`main.js`, `index.html`). **Note the space in the directory name** — quote it in shell commands.\n- `Android/FlyingCarpet/` — Android app (Kotlin). Noise/discovery ports in `app/src/main/java/dev/spiegl/flyingcarpet/`.\n- `Apple/` — iOS + macOS apps (Swift). Protocol code shared by both in `Apple/shared/`, per-platform UI in `Apple/iOS/` and `Apple/macOS/`. **Read `Apple/CLAUDE.md` before changing anything under it** — the Apple platforms have constraints the others don't (no programmatic hotspot, so Apple-to-Apple requires shared network mode; BLE can't pair iPhone↔Mac).\n- `docs/` — design docs (see below). `ARCHITECTURE.md` — connection role model.\n\n## Build & test\n\nRust is a Cargo **workspace** (`core` + `Flying Carpet/src-tauri`):\n\n- `cargo test` — run all Rust tests (includes the Noise/discovery known-answer vectors in `core/src/noise.rs` and `core/src/discovery.rs`). `cargo build` to compile.\n- Desktop app: `cargo tauri dev` (run) / `cargo tauri build` (release). Needs the Tauri CLI and the Linux deps listed in `README.md`.\n- Android: from `Android/FlyingCarpet/`, `./gradlew assembleDebug` / `./gradlew test`. **Set `JAVA_HOME` to the Android Studio JBR** (the bundled JDK) or Gradle fails.\n- Apple: **macOS only**, and outside the Cargo workspace — `cargo build` never touches it. From `Apple/`, `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build`. `DEVELOPMENT_TEAM` is blank in both projects, so signing fails until you set your team in Xcode or pass `DEVELOPMENT_TEAM=YOURTEAMID`. Details in `Apple/CLAUDE.md`.\n\n## Architecture & design docs — read before changing these areas\n\n- **`ARCHITECTURE.md`** — the four independent role axes (transfer direction / BLE peripheral-central / hotspot host-joiner / TCP server-client) and how they map per platform pair. Read before touching connection setup, hosting logic, or BLE.\n- **`docs/shared-network-crypto.md`** — the full v10 cryptographic design (the normative reference for the handshake bytes). Read before touching anything crypto, discovery-auth, or the record/framing layer.\n- **`docs/bluetooth-field-guide.md`** — **read before touching any BLE code on any platform.** The four independent axes (advertising / scanning / bonding / GATT services), seven hard-won laws, a per-platform matrix, and a symptom→cause playbook. Bluetooth bugs here are subtle, intermittent, and platform-asymmetric; several have been re-derived from scratch more than once. Chronological investigation logs: `docs/windows-ble-gatt-0x8000ffff.md`, `docs/ble-bond-asymmetries.md`.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\n- **v10 = Noise.** Every transfer (both modes) runs a `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake; the PSK is `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is the **sole** cipher — the old inner per-chunk AES is gone. v10 is a clean break; v9 peers are rejected. If v10 ships before a later Noise wire change, that change must bump to v11.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then every preamble byte is bound into the Noise prologue. Both platforms of any pair must build the prologue identically (`build_prologue`/`buildPrologue`). Cross-platform KATs guard this — keep all three in sync (Rust `core/src/noise.rs`, Kotlin `NoiseUnitTest`, Swift `Apple/macOS/FlyingCarpetTests/FlyingCarpetTests.swift`; discovery vector: `core/src/discovery.rs` `test_cross_platform_vector` == Android `DiscoveryUnitTest.kt` == the Swift discovery test in the same file). Note the Swift KATs run only under `xcodebuild` on a Mac, so `cargo test` and `./gradlew test` passing is **not** evidence the Swift side still agrees.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer and displays it; never reuse, never user-chosen, never \"remember.\" The entire \"offline crack is worthless\" security argument depends on this (see the crypto doc §7). The discovery HMAC key is derived from the stretched PSK, so no fast hash of the password goes on the wire.\n- **Bluetooth is hotspot-only.** Shared network mode exchanges the password manually (display + type/QR); do **not** re-add BLE to shared mode. Apple-to-Apple can't pair iPhone↔Mac over BLE by design, which is exactly the pair that would need it. Rationale is recorded in `ARCHITECTURE.md` (\"Bluetooth + Shared Network Mode\"). Every platform greys out its BT switch in shared network mode; on Apple that goes through `bluetoothSwitchShouldBeEnabled()`, since restoring the switch on hardware capability alone re-enabled it after a transfer.\n- **Receiver is the anchor.** In both modes the receiver generates the password and is the TCP server (Noise responder); the sender is the TCP client (Noise initiator).\n\n## Conventions & gotchas\n\n- Android: keep `res/layout/` and `res/layout-land/` in sync when changing the UI.\n- `core/Cargo.toml` is pinned to **LF** line endings (`.gitattributes`); don't let an editor rewrite it to CRLF.\n- Header-value bounds (file count / filename length / chunk size sanity checks) and filename sanitization apply to values read from the **Noise-decrypted** stream, not the raw socket.\n"},"items":[{"name":"ARCHITECTURE.md","path":"ARCHITECTURE.md","rawUrl":"https://raw.githubusercontent.com/spieglt/FlyingCarpet/HEAD/ARCHITECTURE.md","title":"System Architecture & Module Boundaries","category":"project-spec","format":"markdown","content":"# FlyingCarpet Connection Architecture\n\n## Overview\n\nA transfer involves four independent role axes. Each device takes one role on each axis. The roles are determined by a combination of **which device is sending/receiving** and **the peer's operating system**.\n\n| Axis | Role A | Role B |\n|------|--------|--------|\n| **Transfer direction** | Sender | Receiver |\n| **BLE** | Peripheral (advertiser) | Central (scanner) |\n| **WiFi hotspot** | Host | Joiner |\n| **TCP** | Server (listener) | Client (connector) |\n\nThese four axes are **not all aligned** — the mapping depends on the platform pair and connection mode.\n\n---\n\n## Bluetooth Roles\n\nBLE role is determined **solely by transfer direction**, the same on every platform:\n\n| Transfer Direction | BLE Role |\n|-------------------|----------|\n| **Sender** | **Peripheral** — advertises GATT service, exposes characteristics |\n| **Receiver** | **Central** — scans for peripheral, connects, reads/writes characteristics |\n\nSource:\n- Linux: `core/src/linux/bluetooth.rs` — `Mode::Send` → peripheral branch (line 85), `else` → central branch (line 156)\n- Windows: `core/src/windows/bluetooth.rs` — `Mode::Send` → peripheral branch (line 74), `else` → central branch (line 144)\n- Android: `MainActivity.kt` — `Mode.Sending` → `advertise()`, `Mode.Receiving` → `scan()` (lines 83-88)\n\n### BLE Data Flow\n\nThe GATT service exposes three characteristics: **OS**, **SSID**, **Password**.\n\nData flow direction depends on who is **hosting the hotspot**, not who is the BLE peripheral/central:\n\n| Hosting device is... | SSID/Password flow |\n|----------------------|-------------------|\n| **Peripheral** (sender is hosting) | Peripheral populates GATT; central reads |\n| **Central** (receiver is hosting) | Central writes SSID/password to peripheral's GATT |\n\nThe OS characteristic always flows both ways: each side needs to know the other's OS.\n\n---\n\n## Hotspot Hosting\n\nThe **hotspot host generates the WiFi password**, creates the hotspot, and acts as **TCP server**. The **joiner** connects to the hotspot and acts as **TCP client**.\n\n### Linux `is_hosting(peer, mode)`\n\n| Peer OS | Linux Hosts? |\n|---------|-------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Windows | **Never** |\n| Linux   | **Only if Receiving** |\n\nSource: `core/src/linux/network.rs` lines 11-20\n\n### Windows `is_hosting(peer, mode)`\n\n| Peer OS | Windows Hosts? |\n|---------|---------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Linux   | **Always** |\n| Windows | **Only if Receiving** |\n\nSource: `core/src/windows/network.rs` lines 674-683\n\n### Android `isHosting()`\n\n| Peer OS | Android Hosts? |\n|---------|---------------|\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Android | **Only if Receiving** |\n| Linux   | **Never** (Linux hosts) |\n| Windows | **Never** (Windows hosts) |\n\nSource: `MainViewModel.kt` lines 112-116\n\n### iOS / macOS\n\niOS and macOS **never host hotspots** — they lack a public hotspot API. The peer always hosts.\n\n---\n\n## Peer OS Selection — why it exists, and when it's hidden\n\nThe \"Select Peer OS\" control (desktop radio buttons, Android spinner, macOS `peerSwitch`) exists to answer one question the app can't otherwise answer before a connection exists: **who hosts the hotspot?** `is_hosting(peer, mode)` is a pure function of *this device's OS*, *the peer's OS*, and *the transfer direction* (see the tables above). This device knows its own OS and direction; the missing input is the **peer's OS**.\n\nThere are three ways to learn the peer's OS, and the UI control is only the third:\n\n1. **Shared network mode** — nobody hosts, so `is_hosting` is never called. Roles are fixed by direction (receiver = TCP server + password generator) and the peer is found by discovery over IP. Peer OS is irrelevant. → control hidden.\n2. **Hotspot mode with Bluetooth** — the BLE GATT **OS characteristic** carries each side's OS automatically (it always flows both ways, see BLE Data Flow). Peer OS is learned over the air. → control hidden.\n3. **Hotspot mode without Bluetooth** — there is no channel to exchange OS before the hotspot exists (chicken-and-egg), so the user supplies it. → **control shown.** This is the *only* case where it's needed.\n\nEvery platform gates the control on exactly this condition. The desktop's `checkStatus()` hides `peerBox` when `connectionMode === 'shared_network' || usingBluetooth`; macOS hides `peerSwitch` in the same two cases; iOS omits the control entirely (see below).\n\nBeyond the hosting decision, knowing the peer OS in hotspot-without-BT also drives two secondary behaviors:\n\n- **Android's SSID is OS-assigned**, not derivable from the password, so an Android *peer* means the joiner must be told the SSID separately (every other peer derives the SSID from the password). This is why the desktop/macOS prompt for SSID **only** when the peer is Android.\n- **Fast-fail on impossible pairs.** Selecting a macOS/iOS peer in hotspot mode is invalid (neither Apple device can host), so the app can reject it up front with \"use Shared Network mode\" instead of failing deep in the join.\n\n### Could it be removed?\n\nNot on host-capable platforms (Windows/Linux/Android) in hotspot-without-BT: the hosting decision genuinely needs the peer OS, and there's no earlier channel to negotiate it. The control *is* effectively the manual stand-in for the BLE OS exchange. The realistic ways to shrink it are to lean harder on the two automatic paths (Bluetooth or shared network) rather than to drop the manual fallback. On Apple platforms the picture is different — see `Apple/CLAUDE.md`, since Apple never hosts and so never needs the peer OS to decide hosting at all.\n\n---\n\n## TCP Roles (Hotspot Mode)\n\nTCP role follows directly from hotspot role:\n\n| Hotspot Role | TCP Role |\n|-------------|----------|\n| **Host** | **Server** — binds `0.0.0.0:3290`, calls `accept()` |\n| **Joiner** | **Client** — connects to host's gateway IP on port 3290 |\n\nSource:\n- Rust: `core/src/lib.rs` `start_tcp()` — `PeerResource::WifiClient` → connect, otherwise → bind+accept\n- Android: `MainViewModel.kt` `startTCP()` — `isHosting()` → `ServerSocket(3290).accept()`, else → `Socket(peerIP, 3290)`\n\n---\n\n## Password Generation & Sharing\n\n**The hotspot host generates the password.** It is shared with the peer via one of:\n\n1. **Bluetooth** — delivered through GATT characteristics (direction depends on who's hosting, see BLE Data Flow above)\n2. **QR code** — host displays QR code, joiner scans it (for mobile peers)\n3. **Manual entry** — host displays password as text, joiner types it in\n\nThe desktop app's `needPassword()` function:\n- Returns **false** → this device is **hosting** → generates and displays the password\n- Returns **true** → this device is **joining** → user must enter the host's password\n\n---\n\n## Complete Platform Pair Matrix (Hotspot Mode)\n\n\"A → B\" means A is sending to B.\n\n| Scenario | BLE: Peripheral | BLE: Central | Hotspot Host | TCP Server | Password Generator |\n|----------|----------------|-------------|-------------|------------|-------------------|\n| Linux → Android | Linux | Android | Linux | Linux | Linux |\n| Android → Linux | Android | Linux | Linux | Linux | Linux |\n| Linux → Windows | Linux | Windows | Windows | Windows | Windows |\n| Windows → Linux | Windows | Linux | Windows | Windows | Windows |\n| Linux → iOS/macOS | Linux | iOS/macOS | Linux | Linux | Linux |\n| Windows → iOS/macOS | Windows | iOS/macOS | Windows | Windows | Windows |\n| Android → iOS/macOS | Android | iOS/macOS | Android | Android | Android |\n| Linux → Linux | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Windows | Sender | Receiver | Receiver | Receiver | Receiver |\n| Android → Android | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Android | Windows | Android | Windows | Windows | Windows |\n| Android → Windows | Android | Windows | Windows | Windows | Windows |\n\n**Key pattern: Hotspot Host = TCP Server = Password Generator.** BLE Peripheral = Sender, BLE Central = Receiver. These two groupings are independent.\n\nIn shared network mode, there is no hotspot host. The **Receiver** takes over as TCP Server and Password Generator — consistent with the same-platform hotspot convention. See the Shared Network Mode section below.\n\n---\n\n## Shared Network Mode\n\nNo hotspot is created. Both devices are already on the same LAN.\n\n### Discovery\n\nBoth devices simultaneously:\n1. Send HMAC-signed announcements via UDP multicast (`239.255.73.67:3290`) and unicast subnet scan\n2. Listen for announcements from the peer\n3. Validate: magic bytes, HMAC (using password-derived key), timestamp window, opposite role\n\n### TCP in Shared Network Mode\n\n| Transfer Direction | TCP Role |\n|-------------------|----------|\n| **Receiver** | **Server** — binds TCP listener on port 3290 *before* discovery starts |\n| **Sender** | **Client** — connects to receiver's IP after discovery completes |\n\nThis is consistent with same-platform hotspot mode, where the receiver always hosts and is the TCP server.\n\n### Password in Shared Network Mode (without Bluetooth)\n\n- **Receiver** generates the password and displays it\n- **Sender** enters the password manually\n\nConsistent with hotspot mode: the receiver is always the \"anchor\" role (host in hotspot, server + password generator in shared network).\n\nThe password is stretched with PBKDF2-HMAC-SHA256 into the Noise pre-shared key (PSK); the discovery HMAC key is derived from that PSK, and the same PSK authenticates the Noise handshake that encrypts the whole transfer with ChaCha20-Poly1305. There is no separate password-derived AES key — v10 removed the old inner per-chunk AES and Noise is the sole cipher. See `docs/shared-network-crypto.md`.\n\n### Bluetooth + Shared Network Mode\n\nThe current BLE protocol is tightly coupled to the hotspot flow — it exchanges peer OS, SSID, and password, and uses `is_hosting()` to determine BLE data flow direction. In shared network mode, peer OS and SSID are irrelevant, and `is_hosting()` doesn't apply.\n\n**Decision: Bluetooth is hotspot-only. Shared network mode exchanges the password manually (receiver displays it; sender types it or scans a QR code). This is deliberate, not a missing feature — do not re-add BLE to shared network mode.**\n\nRationale. The only reason to want BLE in shared mode is to spare users from typing the password, and the users who most need that are **Apple-to-Apple** pairs: Apple devices cannot host a hotspot (no public API), so they are *forced* into shared network mode and always type. But two Apple devices cannot complete the BLE exchange at all. Apple does not support Bluetooth pairing between an iPhone and a Mac — by design; Continuity/AirDrop use BLE only for discovery and move data over Wi-Fi/AWDL, never forming a classic bond — and Flying Carpet's GATT characteristics all require an encrypted, bonded link (`.readEncryptionRequired` / `.writeEncryptionRequired`). No bond is possible, so the encryption-required characteristics can never be read: the exchange fails for exactly the pairing that motivates it.\n\nEvery *other* pairing that could do BLE (non-Apple↔non-Apple, or Apple↔non-Apple) can also almost always use **hotspot mode** instead, where BLE already works. So the residual benefit of shared-mode BLE is a thin slice of transfers, bought at the cost of a new GATT flow across three codebases (Rust core, Android, Apple) plus manual-entry fallbacks. The Apple repo (`FlyingCarpetApple`) actually implemented shared-mode BLE in commit `4a6b889` and then removed it in `b7e9b59` for these reasons; this note records that conclusion for the desktop/Android side so it isn't rediscovered. If reducing password typing ever becomes a priority, improve the out-of-band password UX (QR) that works on every platform pair — not BLE.\n","isInternal":false,"tokens":2767,"sizeBytes":11971},{"name":"CLAUDE.md","path":"Apple/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/spieglt/FlyingCarpet/HEAD/Apple/CLAUDE.md","title":"Apple - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Flying Carpet — Apple (iOS + macOS)\n\nThe **Swift** iOS and macOS apps. Port **3290** throughout, same wire protocol as the Rust core\nand the Android app. See the root `CLAUDE.md` for the repo-wide picture and\n`../ARCHITECTURE.md` for the connection role model.\n\n**Build only on a Mac.** Nothing here compiles as part of the Cargo workspace, and nothing in\n`core/` targets Apple platforms — the Swift code is a full independent port of the protocol, not\na binding over the Rust core. The **Rust core is the reference** this port is tested against.\n\n## Layout\n\n- `shared/` — protocol code shared by both apps (added to both Xcode targets):\n  - `Transfer.swift` — the orchestrator. `runTransfer()` entry point, connection-mode logic,\n    password generation, `Transfer.Delegate` protocol (the per-platform ViewController\n    implements it).\n  - `Discovery.swift` — shared network peer discovery: multicast + unicast UDP on port 3290\n    over raw POSIX sockets, then TCP on 3290.\n  - `Network.swift` — TCP read/write in a `TCPConnectionProtocol` extension; `TCPClient`\n    (hotspot) and `TCPConnectionWrapper` (shared). `AtomicBool` is defined here once.\n    Connections run on `networkQueue`, not main.\n  - `Noise.swift` — `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake + `buildPrologue`.\n  - `Send.swift` / `Receive.swift` — sending/receiving halves.\n  - `Bluetooth.swift` — BLE password exchange (**hotspot mode only**, see invariants).\n- `iOS/FlyingCarpet/` — iOS app. `ViewController.swift` (main UI + BLE + picker delegates),\n  `QRScanner.swift` / `QRScanner.storyboard` (camera QR scanning), `Base.lproj/Main.storyboard`.\n- `macOS/FlyingCarpet/` — macOS app. `ViewController.swift`, `SsidBox.swift`,\n  `Base.lproj/Main.storyboard`.\n\n## Build\n\n```\nDEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \\\n  xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build\n```\n\nFor a specific destination: iOS `-destination 'generic/platform=iOS Simulator'`, macOS\n`-destination 'platform=macOS'`.\n\n**Code signing:** `DEVELOPMENT_TEAM` is deliberately blank in both `project.pbxproj` files, so a\nfresh clone fails with *\"Signing for 'FlyingCarpet' requires a development team.\"* Either select\nyour team once in Xcode (target → Signing & Capabilities → Team; Xcode writes it back into\n`project.pbxproj` as a local modification) or pass it per-build without touching the file:\n\n```\nxcodebuild ... DEVELOPMENT_TEAM=YOURTEAMID -allowProvisioningUpdates build\n```\n\n**The protocol tests live in the macOS target.** `macOS/FlyingCarpetTests/FlyingCarpetTests.swift`\nholds 25 of them, including the cross-platform Noise and discovery known-answer vectors. Run that\ntarget when changing protocol code — the KATs are not duplicated in the iOS target.\n\n`iOS/FlyingCarpetTests/FlyingCarpetTests.swift` holds the 8 tests that can only be exercised on\niOS: the bounded Noise receive buffer (#142) on the platform whose receiver actually failed, and\nthe QR display layout (#141), which is UIKit and has no macOS counterpart. Run it against a\nsimulator:\n\n```\nxcodebuild -project iOS/FlyingCarpet.xcodeproj -scheme FlyingCarpet \\\n  -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \\\n  -only-testing:FlyingCarpetTests test\n```\n\nTwo things to know before running either:\n\n- **`-only-testing:FlyingCarpetTests`.** Without it the scheme also runs `FlyingCarpetUITests`,\n  which is untouched Xcode boilerplate that asserts nothing. On macOS it builds and launches a\n  separate `FlyingCarpetUITests-Runner.app`, which prompts for automation permission and re-prompts\n  on every rebuild, because an ad-hoc-signed bundle gets a new signature each time.\n- **A team on the app target alone breaks the macOS test bundle.** Selecting your team in Xcode\n  writes `DEVELOPMENT_TEAM` into the app target's configs only, and the test bundle then fails to\n  load into its host with *\"mapping process and mapped file (non-platform) have different Team\n  IDs\"*. Pass `DEVELOPMENT_TEAM=YOURTEAMID` to `xcodebuild` so every target agrees. Simulator runs\n  are unaffected.\n\n**SourceKit lies about single files.** Diagnostics that lint one file in isolation report bogus\n\"Cannot find type 'Transfer'/'Bluetooth' in scope\" and \"No such module 'UIKit'\" errors — the\ncross-file and cross-target types resolve fine in a real `xcodebuild`. Trust the build, not the\nisolated-file lint.\n\n## Architecture\n\n- **Two connection modes:** `hotspot` (one device hosts an ad-hoc Wi-Fi network) and\n  `sharedNetwork` (both already on the same LAN; discovery finds the peer over IP). Set from\n  `connectionModeSwitch` (Hotspot=0 / Shared Network=1) on both platforms.\n- **Receiver is the anchor.** In both modes the receiver generates the password, is the TCP\n  server (Noise responder), and (shared network) displays the password; the sender is the TCP\n  client (Noise initiator) and supplies the password.\n- **Apple-to-Apple only works in shared network mode.** Apple no longer lets hotspots be\n  configured programmatically, so hotspot mode is blocked for two Apple devices (error\n  `appleToAppleHotspotErrorMessage`). Use a normal Wi-Fi network or a manually-configured\n  Personal Hotspot the other device has joined.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\nThese mirror the root `CLAUDE.md`; the Swift-specific details are here.\n\n- **v10 = Noise.** Every transfer (both modes) runs the Noise NNpsk0 handshake; the PSK is\n  `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is\n  the sole cipher. v9 peers are rejected.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then\n  every preamble byte is bound into the Noise prologue. Both platforms of any pair must build\n  the prologue identically. Cross-platform KATs guard this — keep the Swift Noise tests\n  (`macOS/FlyingCarpetTests/FlyingCarpetTests.swift`) in sync with `../core/src/noise.rs` and the\n  Kotlin `NoiseUnitTest`; discovery vector in sync with `../core/src/discovery.rs`'s\n  `test_cross_platform_vector`.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer\n  (`generatePassword()` — 10 chars from a 57-symbol confusables-free charset, matching the\n  desktop/Android generators) and displays it; never reuse, never user-chosen, never remember.\n- **Bluetooth is hotspot-only.** Shared network exchanges the password manually (display +\n  type/QR); do **not** re-add BLE to shared mode. The BT switch is greyed out in shared network\n  mode on both platforms — every site that re-enables it goes through\n  `bluetoothSwitchShouldBeEnabled()` (`capable && connectionMode == .hotspot`), because\n  `toggleUI` and `toggleBluetoothUI` both used to restore it on hardware capability alone.\n  Apple-to-Apple can't pair iPhone↔Mac over BLE by design — exactly the pair that would need it.\n\n## Password exchange & QR codes (shared network mode)\n\nThe receiver generates and displays the password; the sender supplies it. To avoid typing, the\nreceiver can display it as a **QR code** and the sender can **scan** it.\n\n- **QR content format (must match across platforms):** shared network QR = the **bare\n  password**. Hotspot QR = `\"ssid;password\"`. Scanners split on `;`: >1 component ⇒ hotspot\n  (ssid + password), otherwise the bare password.\n- **Receivers display the QR** two ways (like Android/desktop): in the main-screen logo image\n  view (`logoImageView`, reset to the app logo in `toggleUI(transferRunning: false)`), and in a\n  popup. macOS uses CoreImage `CIQRCodeGenerator` (`qrCodeImage(from:size:)` → `NSImage`) in the\n  password `NSAlert`'s accessory view; iOS uses the same generator (→ `UIImage`) in a dedicated\n  `QRDisplayViewController` (a `UIAlertController` can't host an image). Both also show the text\n  password for a sender that can't scan.\n- **iOS sender** offers \"Scan QR Code\" in the password prompt (`promptForPassword`), routing to\n  `ScannerViewController` via the `goToQRScanner` segue; the scanned string flows through\n  `codeScanned(result:)`, which sets the password and starts the transfer. iOS can also still\n  type the password.\n- **macOS has no scanner** (Macs aren't portable and may lack a camera). So a macOS *sender*\n  types the password shown on the peer; iOS/Android senders can scan. Net: iOS↔macOS uses QR\n  when iOS is the sender; macOS-as-sender types.\n\n## Peer OS selection on Apple (why macOS has a `peerSwitch` and iOS doesn't)\n\nOn host-capable platforms the \"Select Peer OS\" control feeds `is_hosting(peer, mode)` — who\ncreates the hotspot (see `../ARCHITECTURE.md`, \"Peer OS Selection\"). **Apple devices never host\na hotspot** (no public API — the peer always hosts), so on Apple the peer's OS is *never* needed\nto decide hosting. That collapses the control's job to two leftovers, both **hotspot-mode-\nwithout-Bluetooth only**:\n\n1. **Is the peer Android?** An Android hotspot's SSID is OS-assigned and not derivable from the\n   password, so the joiner must be told the SSID separately (`promptForSsidAndPassword`); every\n   other peer derives the SSID from the password (`getSsidAndKey`), so only the password is\n   needed.\n2. **Fast-fail Apple↔Apple.** Selecting a macOS/iOS peer in hotspot mode is impossible (neither\n   can host), so macOS rejects it up front with the \"use Shared Network mode\" message instead of\n   failing deep in the join.\n\nBoth platforms hide/skip peer OS exactly when it can't matter: it's irrelevant in **shared\nnetwork mode** (discovery + receiver-anchor fixes the roles) and is learned automatically over\nthe BLE **OS characteristic** when **Bluetooth** is on. macOS therefore hides `peerSwitch` in\nshared network mode and when BT is active. **iOS has no peer switch at all**: it never hosts,\nand it gets the Android SSID (and password) out-of-band by scanning the host's `ssid;password`\nQR (`codeScanned`) or over Bluetooth — so it never needs the user to name the peer OS. macOS\nkeeps the switch only because it has no camera scanner and so needs the manual \"peer is Android\n⇒ ask for SSID\" path plus the early Apple↔Apple rejection.\n\nImplication if simplifying: macOS's 5-segment `peerSwitch` is really a binary \"is the peer\nAndroid?\" plus an Apple↔Apple guard. It could be reduced to that (or dropped if macOS gained QR\nscanning / always prompted for SSID), but it's kept 5-way to mirror the desktop UI. Don't remove\nit without replacing those two behaviors.\n\n## Conventions & gotchas\n\n- Storyboards: `iOS/FlyingCarpet/Base.lproj/Main.storyboard`,\n  `macOS/FlyingCarpet/Base.lproj/Main.storyboard`. macOS `peerSwitch` is an\n  `NSSegmentedControl`: Android(0), Linux(1), Windows(2), macOS(3), iOS(4) — see \"Peer OS\n  selection on Apple\" above for why it exists. iOS has no peer switch (it uses Bluetooth or the\n  QR scanner).\n- Duplicate-delivery guards: BLE and PHPicker can deliver twice; password/transfer starts are\n  guarded by `transfer.task == nil` on the main queue. Don't remove these.\n- `AtomicBool` test-and-set must be atomic under its lock (`testAndSet()`), not a `guard` then a\n  separate assignment — that's a TOCTOU race.\n- `.gitignore` here covers the Xcode-local noise (`xcuserdata`, `xcshareddata`, `swiftpm`,\n  `xcdebugger`, `.DS_Store`). Keep it — those directories embed local usernames and window state.\n\n## History\n\nThis code was developed in a separate `FlyingCarpetApple` repository and copied here without its\ngit history when v10 was released, so `git log` on these files starts at the import commit. The\niOS build submitted to the App Store as **10.0.0 (build 1)** is tagged `ios-10.0.0` in that old\nrepo. `../docs/apple-receive-throughput.md` is the one investigation doc that came across with\nit.\n","isInternal":false,"tokens":2939,"sizeBytes":11759},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/spieglt/FlyingCarpet/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Flying Carpet\n\nEncrypted, no-internet file transfer between **Android, iOS, Linux, macOS, and Windows**, over either an ad-hoc Wi-Fi hotspot (one device hosts) or a **shared network** both devices are already on. Two devices, Wi-Fi (or ethernet), optionally Bluetooth. Port **3290** throughout.\n\n## Three implementations, one wire protocol (critical)\n\nAll five platforms live in this repo, in **three independent implementations of the same wire protocol**. A change to the on-the-wire format in one is a breaking change unless mirrored in the other two:\n\n- **Rust** (`core/`) — the **reference implementation** the other two are tested against. Ships the Tauri desktop app (`Flying Carpet/`) for Windows + Linux.\n- **Kotlin** (`Android/FlyingCarpet/`) — Android.\n- **Swift** (`Apple/`) — iOS + macOS. A full port, **not** a binding over the Rust core. Build only on a Mac; see `Apple/CLAUDE.md`.\n\nWhen touching the protocol (discovery bytes, version/mode preamble, Noise handshake, framing), update **all three implementations** and their cross-platform known-answer tests together.\n\n### Where code lives vs. where binaries ship (don't conflate these)\n\n- **The Rust code builds for Windows and Linux only.** `core/src/` has just `windows/` and `linux/`; `lib.rs` cfg-selects `network`/`bluetooth` on those two `target_os` values, and there is no `target_os = \"macos\"` anywhere in `core/`. A Tauri/`wry`/`webkit2gtk` change therefore affects **two** desktop platforms, not three — macOS is served by the Swift app in `Apple/`, which shares no code with `core/`.\n- **The macOS binary is released from this repo's Releases page** as a **`.zip` of the `.app`** (`macOS_FlyingCarpet_<version>.zip`) — *not* a `.dmg`. The `.dmg` format ended at v8.0.1; v9.0.0 switched to `.zip`. Don't infer from a macOS binary existing that the Rust code targets macOS — it's built from `Apple/macOS/`. There is also a Homebrew **cask** (`brew install flying-carpet`), which is maintained outside this repo.\n- `tauri.conf.json` still lists `icons/icon.icns` — genuinely stale, intentionally left alone. Not evidence of macOS support either.\n- The Swift code was developed in a separate `FlyingCarpetApple` repo and imported without history at the v10 release, so `git log` on `Apple/` starts at the import commit. Commit hashes cited for Swift changes in the older docs (e.g. `4c59af6` in `docs/bluetooth-field-guide.md`, `4a6b889`/`b7e9b59` in `ARCHITECTURE.md`) belong to that repo and won't resolve here.\n\n## Layout\n\n- `core/` — Rust core crate `flying-carpet-core` (v10). Platform-split: `core/src/{windows,linux}/` for network/bluetooth/peripheral/central; the `bluetooth` module is `cfg`-selected per-OS in `lib.rs`. Key files: `lib.rs` (`start_transfer` entry point), `discovery.rs`, `noise.rs`, `sending.rs`/`receiving.rs`.\n- `Flying Carpet/` — Tauri desktop app. Rust backend in `Flying Carpet/src-tauri/` (workspace member), JS/HTML frontend in `Flying Carpet/src/` (`main.js`, `index.html`). **Note the space in the directory name** — quote it in shell commands.\n- `Android/FlyingCarpet/` — Android app (Kotlin). Noise/discovery ports in `app/src/main/java/dev/spiegl/flyingcarpet/`.\n- `Apple/` — iOS + macOS apps (Swift). Protocol code shared by both in `Apple/shared/`, per-platform UI in `Apple/iOS/` and `Apple/macOS/`. **Read `Apple/CLAUDE.md` before changing anything under it** — the Apple platforms have constraints the others don't (no programmatic hotspot, so Apple-to-Apple requires shared network mode; BLE can't pair iPhone↔Mac).\n- `docs/` — design docs (see below). `ARCHITECTURE.md` — connection role model.\n\n## Build & test\n\nRust is a Cargo **workspace** (`core` + `Flying Carpet/src-tauri`):\n\n- `cargo test` — run all Rust tests (includes the Noise/discovery known-answer vectors in `core/src/noise.rs` and `core/src/discovery.rs`). `cargo build` to compile.\n- Desktop app: `cargo tauri dev` (run) / `cargo tauri build` (release). Needs the Tauri CLI and the Linux deps listed in `README.md`.\n- Android: from `Android/FlyingCarpet/`, `./gradlew assembleDebug` / `./gradlew test`. **Set `JAVA_HOME` to the Android Studio JBR** (the bundled JDK) or Gradle fails.\n- Apple: **macOS only**, and outside the Cargo workspace — `cargo build` never touches it. From `Apple/`, `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build`. `DEVELOPMENT_TEAM` is blank in both projects, so signing fails until you set your team in Xcode or pass `DEVELOPMENT_TEAM=YOURTEAMID`. Details in `Apple/CLAUDE.md`.\n\n## Architecture & design docs — read before changing these areas\n\n- **`ARCHITECTURE.md`** — the four independent role axes (transfer direction / BLE peripheral-central / hotspot host-joiner / TCP server-client) and how they map per platform pair. Read before touching connection setup, hosting logic, or BLE.\n- **`docs/shared-network-crypto.md`** — the full v10 cryptographic design (the normative reference for the handshake bytes). Read before touching anything crypto, discovery-auth, or the record/framing layer.\n- **`docs/bluetooth-field-guide.md`** — **read before touching any BLE code on any platform.** The four independent axes (advertising / scanning / bonding / GATT services), seven hard-won laws, a per-platform matrix, and a symptom→cause playbook. Bluetooth bugs here are subtle, intermittent, and platform-asymmetric; several have been re-derived from scratch more than once. Chronological investigation logs: `docs/windows-ble-gatt-0x8000ffff.md`, `docs/ble-bond-asymmetries.md`.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\n- **v10 = Noise.** Every transfer (both modes) runs a `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake; the PSK is `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is the **sole** cipher — the old inner per-chunk AES is gone. v10 is a clean break; v9 peers are rejected. If v10 ships before a later Noise wire change, that change must bump to v11.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then every preamble byte is bound into the Noise prologue. Both platforms of any pair must build the prologue identically (`build_prologue`/`buildPrologue`). Cross-platform KATs guard this — keep all three in sync (Rust `core/src/noise.rs`, Kotlin `NoiseUnitTest`, Swift `Apple/macOS/FlyingCarpetTests/FlyingCarpetTests.swift`; discovery vector: `core/src/discovery.rs` `test_cross_platform_vector` == Android `DiscoveryUnitTest.kt` == the Swift discovery test in the same file). Note the Swift KATs run only under `xcodebuild` on a Mac, so `cargo test` and `./gradlew test` passing is **not** evidence the Swift side still agrees.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer and displays it; never reuse, never user-chosen, never \"remember.\" The entire \"offline crack is worthless\" security argument depends on this (see the crypto doc §7). The discovery HMAC key is derived from the stretched PSK, so no fast hash of the password goes on the wire.\n- **Bluetooth is hotspot-only.** Shared network mode exchanges the password manually (display + type/QR); do **not** re-add BLE to shared mode. Apple-to-Apple can't pair iPhone↔Mac over BLE by design, which is exactly the pair that would need it. Rationale is recorded in `ARCHITECTURE.md` (\"Bluetooth + Shared Network Mode\"). Every platform greys out its BT switch in shared network mode; on Apple that goes through `bluetoothSwitchShouldBeEnabled()`, since restoring the switch on hardware capability alone re-enabled it after a transfer.\n- **Receiver is the anchor.** In both modes the receiver generates the password and is the TCP server (Noise responder); the sender is the TCP client (Noise initiator).\n\n## Conventions & gotchas\n\n- Android: keep `res/layout/` and `res/layout-land/` in sync when changing the UI.\n- `core/Cargo.toml` is pinned to **LF** line endings (`.gitattributes`); don't let an editor rewrite it to CRLF.\n- Header-value bounds (file count / filename length / chunk size sanity checks) and filename sanitization apply to values read from the **Noise-decrypted** stream, not the raw socket.\n","isInternal":false,"tokens":2103,"sizeBytes":8290}],"systemPromptSnippet":"<yakaai_skills repo=\"spieglt/FlyingCarpet\">\n<!-- File: ARCHITECTURE.md (Tokens: ~2767 | Category: project-spec) -->\n# FlyingCarpet Connection Architecture\n\n## Overview\n\nA transfer involves four independent role axes. Each device takes one role on each axis. The roles are determined by a combination of **which device is sending/receiving** and **the peer's operating system**.\n\n| Axis | Role A | Role B |\n|------|--------|--------|\n| **Transfer direction** | Sender | Receiver |\n| **BLE** | Peripheral (advertiser) | Central (scanner) |\n| **WiFi hotspot** | Host | Joiner |\n| **TCP** | Server (listener) | Client (connector) |\n\nThese four axes are **not all aligned** — the mapping depends on the platform pair and connection mode.\n\n---\n\n## Bluetooth Roles\n\nBLE role is determined **solely by transfer direction**, the same on every platform:\n\n| Transfer Direction | BLE Role |\n|-------------------|----------|\n| **Sender** | **Peripheral** — advertises GATT service, exposes characteristics |\n| **Receiver** | **Central** — scans for peripheral, connects, reads/writes characteristics |\n\nSource:\n- Linux: `core/src/linux/bluetooth.rs` — `Mode::Send` → peripheral branch (line 85), `else` → central branch (line 156)\n- Windows: `core/src/windows/bluetooth.rs` — `Mode::Send` → peripheral branch (line 74), `else` → central branch (line 144)\n- Android: `MainActivity.kt` — `Mode.Sending` → `advertise()`, `Mode.Receiving` → `scan()` (lines 83-88)\n\n### BLE Data Flow\n\nThe GATT service exposes three characteristics: **OS**, **SSID**, **Password**.\n\nData flow direction depends on who is **hosting the hotspot**, not who is the BLE peripheral/central:\n\n| Hosting device is... | SSID/Password flow |\n|----------------------|-------------------|\n| **Peripheral** (sender is hosting) | Peripheral populates GATT; central reads |\n| **Central** (receiver is hosting) | Central writes SSID/password to peripheral's GATT |\n\nThe OS characteristic always flows both ways: each side needs to know the other's OS.\n\n---\n\n## Hotspot Hosting\n\nThe **hotspot host generates the WiFi password**, creates the hotspot, and acts as **TCP server**. The **joiner** connects to the hotspot and acts as **TCP client**.\n\n### Linux `is_hosting(peer, mode)`\n\n| Peer OS | Linux Hosts? |\n|---------|-------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Windows | **Never** |\n| Linux   | **Only if Receiving** |\n\nSource: `core/src/linux/network.rs` lines 11-20\n\n### Windows `is_hosting(peer, mode)`\n\n| Peer OS | Windows Hosts? |\n|---------|---------------|\n| Android | **Always** |\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Linux   | **Always** |\n| Windows | **Only if Receiving** |\n\nSource: `core/src/windows/network.rs` lines 674-683\n\n### Android `isHosting()`\n\n| Peer OS | Android Hosts? |\n|---------|---------------|\n| iOS     | **Always** |\n| macOS   | **Always** |\n| Android | **Only if Receiving** |\n| Linux   | **Never** (Linux hosts) |\n| Windows | **Never** (Windows hosts) |\n\nSource: `MainViewModel.kt` lines 112-116\n\n### iOS / macOS\n\niOS and macOS **never host hotspots** — they lack a public hotspot API. The peer always hosts.\n\n---\n\n## Peer OS Selection — why it exists, and when it's hidden\n\nThe \"Select Peer OS\" control (desktop radio buttons, Android spinner, macOS `peerSwitch`) exists to answer one question the app can't otherwise answer before a connection exists: **who hosts the hotspot?** `is_hosting(peer, mode)` is a pure function of *this device's OS*, *the peer's OS*, and *the transfer direction* (see the tables above). This device knows its own OS and direction; the missing input is the **peer's OS**.\n\nThere are three ways to learn the peer's OS, and the UI control is only the third:\n\n1. **Shared network mode** — nobody hosts, so `is_hosting` is never called. Roles are fixed by direction (receiver = TCP server + password generator) and the peer is found by discovery over IP. Peer OS is irrelevant. → control hidden.\n2. **Hotspot mode with Bluetooth** — the BLE GATT **OS characteristic** carries each side's OS automatically (it always flows both ways, see BLE Data Flow). Peer OS is learned over the air. → control hidden.\n3. **Hotspot mode without Bluetooth** — there is no channel to exchange OS before the hotspot exists (chicken-and-egg), so the user supplies it. → **control shown.** This is the *only* case where it's needed.\n\nEvery platform gates the control on exactly this condition. The desktop's `checkStatus()` hides `peerBox` when `connectionMode === 'shared_network' || usingBluetooth`; macOS hides `peerSwitch` in the same two cases; iOS omits the control entirely (see below).\n\nBeyond the hosting decision, knowing the peer OS in hotspot-without-BT also drives two secondary behaviors:\n\n- **Android's SSID is OS-assigned**, not derivable from the password, so an Android *peer* means the joiner must be told the SSID separately (every other peer derives the SSID from the password). This is why the desktop/macOS prompt for SSID **only** when the peer is Android.\n- **Fast-fail on impossible pairs.** Selecting a macOS/iOS peer in hotspot mode is invalid (neither Apple device can host), so the app can reject it up front with \"use Shared Network mode\" instead of failing deep in the join.\n\n### Could it be removed?\n\nNot on host-capable platforms (Windows/Linux/Android) in hotspot-without-BT: the hosting decision genuinely needs the peer OS, and there's no earlier channel to negotiate it. The control *is* effectively the manual stand-in for the BLE OS exchange. The realistic ways to shrink it are to lean harder on the two automatic paths (Bluetooth or shared network) rather than to drop the manual fallback. On Apple platforms the picture is different — see `Apple/CLAUDE.md`, since Apple never hosts and so never needs the peer OS to decide hosting at all.\n\n---\n\n## TCP Roles (Hotspot Mode)\n\nTCP role follows directly from hotspot role:\n\n| Hotspot Role | TCP Role |\n|-------------|----------|\n| **Host** | **Server** — binds `0.0.0.0:3290`, calls `accept()` |\n| **Joiner** | **Client** — connects to host's gateway IP on port 3290 |\n\nSource:\n- Rust: `core/src/lib.rs` `start_tcp()` — `PeerResource::WifiClient` → connect, otherwise → bind+accept\n- Android: `MainViewModel.kt` `startTCP()` — `isHosting()` → `ServerSocket(3290).accept()`, else → `Socket(peerIP, 3290)`\n\n---\n\n## Password Generation & Sharing\n\n**The hotspot host generates the password.** It is shared with the peer via one of:\n\n1. **Bluetooth** — delivered through GATT characteristics (direction depends on who's hosting, see BLE Data Flow above)\n2. **QR code** — host displays QR code, joiner scans it (for mobile peers)\n3. **Manual entry** — host displays password as text, joiner types it in\n\nThe desktop app's `needPassword()` function:\n- Returns **false** → this device is **hosting** → generates and displays the password\n- Returns **true** → this device is **joining** → user must enter the host's password\n\n---\n\n## Complete Platform Pair Matrix (Hotspot Mode)\n\n\"A → B\" means A is sending to B.\n\n| Scenario | BLE: Peripheral | BLE: Central | Hotspot Host | TCP Server | Password Generator |\n|----------|----------------|-------------|-------------|------------|-------------------|\n| Linux → Android | Linux | Android | Linux | Linux | Linux |\n| Android → Linux | Android | Linux | Linux | Linux | Linux |\n| Linux → Windows | Linux | Windows | Windows | Windows | Windows |\n| Windows → Linux | Windows | Linux | Windows | Windows | Windows |\n| Linux → iOS/macOS | Linux | iOS/macOS | Linux | Linux | Linux |\n| Windows → iOS/macOS | Windows | iOS/macOS | Windows | Windows | Windows |\n| Android → iOS/macOS | Android | iOS/macOS | Android | Android | Android |\n| Linux → Linux | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Windows | Sender | Receiver | Receiver | Receiver | Receiver |\n| Android → Android | Sender | Receiver | Receiver | Receiver | Receiver |\n| Windows → Android | Windows | Android | Windows | Windows | Windows |\n| Android → Windows | Android | Windows | Windows | Windows | Windows |\n\n**Key pattern: Hotspot Host = TCP Server = Password Generator.** BLE Peripheral = Sender, BLE Central = Receiver. These two groupings are independent.\n\nIn shared network mode, there is no hotspot host. The **Receiver** takes over as TCP Server and Password Generator — consistent with the same-platform hotspot convention. See the Shared Network Mode section below.\n\n---\n\n## Shared Network Mode\n\nNo hotspot is created. Both devices are already on the same LAN.\n\n### Discovery\n\nBoth devices simultaneously:\n1. Send HMAC-signed announcements via UDP multicast (`239.255.73.67:3290`) and unicast subnet scan\n2. Listen for announcements from the peer\n3. Validate: magic bytes, HMAC (using password-derived key), timestamp window, opposite role\n\n### TCP in Shared Network Mode\n\n| Transfer Direction | TCP Role |\n|-------------------|----------|\n| **Receiver** | **Server** — binds TCP listener on port 3290 *before* discovery starts |\n| **Sender** | **Client** — connects to receiver's IP after discovery completes |\n\nThis is consistent with same-platform hotspot mode, where the receiver always hosts and is the TCP server.\n\n### Password in Shared Network Mode (without Bluetooth)\n\n- **Receiver** generates the password and displays it\n- **Sender** enters the password manually\n\nConsistent with hotspot mode: the receiver is always the \"anchor\" role (host in hotspot, server + password generator in shared network).\n\nThe password is stretched with PBKDF2-HMAC-SHA256 into the Noise pre-shared key (PSK); the discovery HMAC key is derived from that PSK, and the same PSK authenticates the Noise handshake that encrypts the whole transfer with ChaCha20-Poly1305. There is no separate password-derived AES key — v10 removed the old inner per-chunk AES and Noise is the sole cipher. See `docs/shared-network-crypto.md`.\n\n### Bluetooth + Shared Network Mode\n\nThe current BLE protocol is tightly coupled to the hotspot flow — it exchanges peer OS, SSID, and password, and uses `is_hosting()` to determine BLE data flow direction. In shared network mode, peer OS and SSID are irrelevant, and `is_hosting()` doesn't apply.\n\n**Decision: Bluetooth is hotspot-only. Shared network mode exchanges the password manually (receiver displays it; sender types it or scans a QR code). This is deliberate, not a missing feature — do not re-add BLE to shared network mode.**\n\nRationale. The only reason to want BLE in shared mode is to spare users from typing the password, and the users who most need that are **Apple-to-Apple** pairs: Apple devices cannot host a hotspot (no public API), so they are *forced* into shared network mode and always type. But two Apple devices cannot complete the BLE exchange at all. Apple does not support Bluetooth pairing between an iPhone and a Mac — by design; Continuity/AirDrop use BLE only for discovery and move data over Wi-Fi/AWDL, never forming a classic bond — and Flying Carpet's GATT characteristics all require an encrypted, bonded link (`.readEncryptionRequired` / `.writeEncryptionRequired`). No bond is possible, so the encryption-required characteristics can never be read: the exchange fails for exactly the pairing that motivates it.\n\nEvery *other* pairing that could do BLE (non-Apple↔non-Apple, or Apple↔non-Apple) can also almost always use **hotspot mode** instead, where BLE already works. So the residual benefit of shared-mode BLE is a thin slice of transfers, bought at the cost of a new GATT flow across three codebases (Rust core, Android, Apple) plus manual-entry fallbacks. The Apple repo (`FlyingCarpetApple`) actually implemented shared-mode BLE in commit `4a6b889` and then removed it in `b7e9b59` for these reasons; this note records that conclusion for the desktop/Android side so it isn't rediscovered. If reducing password typing ever becomes a priority, improve the out-of-band password UX (QR) that works on every platform pair — not BLE.\n\n<!-- File: Apple/CLAUDE.md (Tokens: ~2939 | Category: claude-rule) -->\n# Flying Carpet — Apple (iOS + macOS)\n\nThe **Swift** iOS and macOS apps. Port **3290** throughout, same wire protocol as the Rust core\nand the Android app. See the root `CLAUDE.md` for the repo-wide picture and\n`../ARCHITECTURE.md` for the connection role model.\n\n**Build only on a Mac.** Nothing here compiles as part of the Cargo workspace, and nothing in\n`core/` targets Apple platforms — the Swift code is a full independent port of the protocol, not\na binding over the Rust core. The **Rust core is the reference** this port is tested against.\n\n## Layout\n\n- `shared/` — protocol code shared by both apps (added to both Xcode targets):\n  - `Transfer.swift` — the orchestrator. `runTransfer()` entry point, connection-mode logic,\n    password generation, `Transfer.Delegate` protocol (the per-platform ViewController\n    implements it).\n  - `Discovery.swift` — shared network peer discovery: multicast + unicast UDP on port 3290\n    over raw POSIX sockets, then TCP on 3290.\n  - `Network.swift` — TCP read/write in a `TCPConnectionProtocol` extension; `TCPClient`\n    (hotspot) and `TCPConnectionWrapper` (shared). `AtomicBool` is defined here once.\n    Connections run on `networkQueue`, not main.\n  - `Noise.swift` — `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake + `buildPrologue`.\n  - `Send.swift` / `Receive.swift` — sending/receiving halves.\n  - `Bluetooth.swift` — BLE password exchange (**hotspot mode only**, see invariants).\n- `iOS/FlyingCarpet/` — iOS app. `ViewController.swift` (main UI + BLE + picker delegates),\n  `QRScanner.swift` / `QRScanner.storyboard` (camera QR scanning), `Base.lproj/Main.storyboard`.\n- `macOS/FlyingCarpet/` — macOS app. `ViewController.swift`, `SsidBox.swift`,\n  `Base.lproj/Main.storyboard`.\n\n## Build\n\n```\nDEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \\\n  xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build\n```\n\nFor a specific destination: iOS `-destination 'generic/platform=iOS Simulator'`, macOS\n`-destination 'platform=macOS'`.\n\n**Code signing:** `DEVELOPMENT_TEAM` is deliberately blank in both `project.pbxproj` files, so a\nfresh clone fails with *\"Signing for 'FlyingCarpet' requires a development team.\"* Either select\nyour team once in Xcode (target → Signing & Capabilities → Team; Xcode writes it back into\n`project.pbxproj` as a local modification) or pass it per-build without touching the file:\n\n```\nxcodebuild ... DEVELOPMENT_TEAM=YOURTEAMID -allowProvisioningUpdates build\n```\n\n**The protocol tests live in the macOS target.** `macOS/FlyingCarpetTests/FlyingCarpetTests.swift`\nholds 25 of them, including the cross-platform Noise and discovery known-answer vectors. Run that\ntarget when changing protocol code — the KATs are not duplicated in the iOS target.\n\n`iOS/FlyingCarpetTests/FlyingCarpetTests.swift` holds the 8 tests that can only be exercised on\niOS: the bounded Noise receive buffer (#142) on the platform whose receiver actually failed, and\nthe QR display layout (#141), which is UIKit and has no macOS counterpart. Run it against a\nsimulator:\n\n```\nxcodebuild -project iOS/FlyingCarpet.xcodeproj -scheme FlyingCarpet \\\n  -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \\\n  -only-testing:FlyingCarpetTests test\n```\n\nTwo things to know before running either:\n\n- **`-only-testing:FlyingCarpetTests`.** Without it the scheme also runs `FlyingCarpetUITests`,\n  which is untouched Xcode boilerplate that asserts nothing. On macOS it builds and launches a\n  separate `FlyingCarpetUITests-Runner.app`, which prompts for automation permission and re-prompts\n  on every rebuild, because an ad-hoc-signed bundle gets a new signature each time.\n- **A team on the app target alone breaks the macOS test bundle.** Selecting your team in Xcode\n  writes `DEVELOPMENT_TEAM` into the app target's configs only, and the test bundle then fails to\n  load into its host with *\"mapping process and mapped file (non-platform) have different Team\n  IDs\"*. Pass `DEVELOPMENT_TEAM=YOURTEAMID` to `xcodebuild` so every target agrees. Simulator runs\n  are unaffected.\n\n**SourceKit lies about single files.** Diagnostics that lint one file in isolation report bogus\n\"Cannot find type 'Transfer'/'Bluetooth' in scope\" and \"No such module 'UIKit'\" errors — the\ncross-file and cross-target types resolve fine in a real `xcodebuild`. Trust the build, not the\nisolated-file lint.\n\n## Architecture\n\n- **Two connection modes:** `hotspot` (one device hosts an ad-hoc Wi-Fi network) and\n  `sharedNetwork` (both already on the same LAN; discovery finds the peer over IP). Set from\n  `connectionModeSwitch` (Hotspot=0 / Shared Network=1) on both platforms.\n- **Receiver is the anchor.** In both modes the receiver generates the password, is the TCP\n  server (Noise responder), and (shared network) displays the password; the sender is the TCP\n  client (Noise initiator) and supplies the password.\n- **Apple-to-Apple only works in shared network mode.** Apple no longer lets hotspots be\n  configured programmatically, so hotspot mode is blocked for two Apple devices (error\n  `appleToAppleHotspotErrorMessage`). Use a normal Wi-Fi network or a manually-configured\n  Personal Hotspot the other device has joined.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\nThese mirror the root `CLAUDE.md`; the Swift-specific details are here.\n\n- **v10 = Noise.** Every transfer (both modes) runs the Noise NNpsk0 handshake; the PSK is\n  `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is\n  the sole cipher. v9 peers are rejected.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then\n  every preamble byte is bound into the Noise prologue. Both platforms of any pair must build\n  the prologue identically. Cross-platform KATs guard this — keep the Swift Noise tests\n  (`macOS/FlyingCarpetTests/FlyingCarpetTests.swift`) in sync with `../core/src/noise.rs` and the\n  Kotlin `NoiseUnitTest`; discovery vector in sync with `../core/src/discovery.rs`'s\n  `test_cross_platform_vector`.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer\n  (`generatePassword()` — 10 chars from a 57-symbol confusables-free charset, matching the\n  desktop/Android generators) and displays it; never reuse, never user-chosen, never remember.\n- **Bluetooth is hotspot-only.** Shared network exchanges the password manually (display +\n  type/QR); do **not** re-add BLE to shared mode. The BT switch is greyed out in shared network\n  mode on both platforms — every site that re-enables it goes through\n  `bluetoothSwitchShouldBeEnabled()` (`capable && connectionMode == .hotspot`), because\n  `toggleUI` and `toggleBluetoothUI` both used to restore it on hardware capability alone.\n  Apple-to-Apple can't pair iPhone↔Mac over BLE by design — exactly the pair that would need it.\n\n## Password exchange & QR codes (shared network mode)\n\nThe receiver generates and displays the password; the sender supplies it. To avoid typing, the\nreceiver can display it as a **QR code** and the sender can **scan** it.\n\n- **QR content format (must match across platforms):** shared network QR = the **bare\n  password**. Hotspot QR = `\"ssid;password\"`. Scanners split on `;`: >1 component ⇒ hotspot\n  (ssid + password), otherwise the bare password.\n- **Receivers display the QR** two ways (like Android/desktop): in the main-screen logo image\n  view (`logoImageView`, reset to the app logo in `toggleUI(transferRunning: false)`), and in a\n  popup. macOS uses CoreImage `CIQRCodeGenerator` (`qrCodeImage(from:size:)` → `NSImage`) in the\n  password `NSAlert`'s accessory view; iOS uses the same generator (→ `UIImage`) in a dedicated\n  `QRDisplayViewController` (a `UIAlertController` can't host an image). Both also show the text\n  password for a sender that can't scan.\n- **iOS sender** offers \"Scan QR Code\" in the password prompt (`promptForPassword`), routing to\n  `ScannerViewController` via the `goToQRScanner` segue; the scanned string flows through\n  `codeScanned(result:)`, which sets the password and starts the transfer. iOS can also still\n  type the password.\n- **macOS has no scanner** (Macs aren't portable and may lack a camera). So a macOS *sender*\n  types the password shown on the peer; iOS/Android senders can scan. Net: iOS↔macOS uses QR\n  when iOS is the sender; macOS-as-sender types.\n\n## Peer OS selection on Apple (why macOS has a `peerSwitch` and iOS doesn't)\n\nOn host-capable platforms the \"Select Peer OS\" control feeds `is_hosting(peer, mode)` — who\ncreates the hotspot (see `../ARCHITECTURE.md`, \"Peer OS Selection\"). **Apple devices never host\na hotspot** (no public API — the peer always hosts), so on Apple the peer's OS is *never* needed\nto decide hosting. That collapses the control's job to two leftovers, both **hotspot-mode-\nwithout-Bluetooth only**:\n\n1. **Is the peer Android?** An Android hotspot's SSID is OS-assigned and not derivable from the\n   password, so the joiner must be told the SSID separately (`promptForSsidAndPassword`); every\n   other peer derives the SSID from the password (`getSsidAndKey`), so only the password is\n   needed.\n2. **Fast-fail Apple↔Apple.** Selecting a macOS/iOS peer in hotspot mode is impossible (neither\n   can host), so macOS rejects it up front with the \"use Shared Network mode\" message instead of\n   failing deep in the join.\n\nBoth platforms hide/skip peer OS exactly when it can't matter: it's irrelevant in **shared\nnetwork mode** (discovery + receiver-anchor fixes the roles) and is learned automatically over\nthe BLE **OS characteristic** when **Bluetooth** is on. macOS therefore hides `peerSwitch` in\nshared network mode and when BT is active. **iOS has no peer switch at all**: it never hosts,\nand it gets the Android SSID (and password) out-of-band by scanning the host's `ssid;password`\nQR (`codeScanned`) or over Bluetooth — so it never needs the user to name the peer OS. macOS\nkeeps the switch only because it has no camera scanner and so needs the manual \"peer is Android\n⇒ ask for SSID\" path plus the early Apple↔Apple rejection.\n\nImplication if simplifying: macOS's 5-segment `peerSwitch` is really a binary \"is the peer\nAndroid?\" plus an Apple↔Apple guard. It could be reduced to that (or dropped if macOS gained QR\nscanning / always prompted for SSID), but it's kept 5-way to mirror the desktop UI. Don't remove\nit without replacing those two behaviors.\n\n## Conventions & gotchas\n\n- Storyboards: `iOS/FlyingCarpet/Base.lproj/Main.storyboard`,\n  `macOS/FlyingCarpet/Base.lproj/Main.storyboard`. macOS `peerSwitch` is an\n  `NSSegmentedControl`: Android(0), Linux(1), Windows(2), macOS(3), iOS(4) — see \"Peer OS\n  selection on Apple\" above for why it exists. iOS has no peer switch (it uses Bluetooth or the\n  QR scanner).\n- Duplicate-delivery guards: BLE and PHPicker can deliver twice; password/transfer starts are\n  guarded by `transfer.task == nil` on the main queue. Don't remove these.\n- `AtomicBool` test-and-set must be atomic under its lock (`testAndSet()`), not a `guard` then a\n  separate assignment — that's a TOCTOU race.\n- `.gitignore` here covers the Xcode-local noise (`xcuserdata`, `xcshareddata`, `swiftpm`,\n  `xcdebugger`, `.DS_Store`). Keep it — those directories embed local usernames and window state.\n\n## History\n\nThis code was developed in a separate `FlyingCarpetApple` repository and copied here without its\ngit history when v10 was released, so `git log` on these files starts at the import commit. The\niOS build submitted to the App Store as **10.0.0 (build 1)** is tagged `ios-10.0.0` in that old\nrepo. `../docs/apple-receive-throughput.md` is the one investigation doc that came across with\nit.\n\n<!-- File: CLAUDE.md (Tokens: ~2103 | Category: claude-rule) -->\n# Flying Carpet\n\nEncrypted, no-internet file transfer between **Android, iOS, Linux, macOS, and Windows**, over either an ad-hoc Wi-Fi hotspot (one device hosts) or a **shared network** both devices are already on. Two devices, Wi-Fi (or ethernet), optionally Bluetooth. Port **3290** throughout.\n\n## Three implementations, one wire protocol (critical)\n\nAll five platforms live in this repo, in **three independent implementations of the same wire protocol**. A change to the on-the-wire format in one is a breaking change unless mirrored in the other two:\n\n- **Rust** (`core/`) — the **reference implementation** the other two are tested against. Ships the Tauri desktop app (`Flying Carpet/`) for Windows + Linux.\n- **Kotlin** (`Android/FlyingCarpet/`) — Android.\n- **Swift** (`Apple/`) — iOS + macOS. A full port, **not** a binding over the Rust core. Build only on a Mac; see `Apple/CLAUDE.md`.\n\nWhen touching the protocol (discovery bytes, version/mode preamble, Noise handshake, framing), update **all three implementations** and their cross-platform known-answer tests together.\n\n### Where code lives vs. where binaries ship (don't conflate these)\n\n- **The Rust code builds for Windows and Linux only.** `core/src/` has just `windows/` and `linux/`; `lib.rs` cfg-selects `network`/`bluetooth` on those two `target_os` values, and there is no `target_os = \"macos\"` anywhere in `core/`. A Tauri/`wry`/`webkit2gtk` change therefore affects **two** desktop platforms, not three — macOS is served by the Swift app in `Apple/`, which shares no code with `core/`.\n- **The macOS binary is released from this repo's Releases page** as a **`.zip` of the `.app`** (`macOS_FlyingCarpet_<version>.zip`) — *not* a `.dmg`. The `.dmg` format ended at v8.0.1; v9.0.0 switched to `.zip`. Don't infer from a macOS binary existing that the Rust code targets macOS — it's built from `Apple/macOS/`. There is also a Homebrew **cask** (`brew install flying-carpet`), which is maintained outside this repo.\n- `tauri.conf.json` still lists `icons/icon.icns` — genuinely stale, intentionally left alone. Not evidence of macOS support either.\n- The Swift code was developed in a separate `FlyingCarpetApple` repo and imported without history at the v10 release, so `git log` on `Apple/` starts at the import commit. Commit hashes cited for Swift changes in the older docs (e.g. `4c59af6` in `docs/bluetooth-field-guide.md`, `4a6b889`/`b7e9b59` in `ARCHITECTURE.md`) belong to that repo and won't resolve here.\n\n## Layout\n\n- `core/` — Rust core crate `flying-carpet-core` (v10). Platform-split: `core/src/{windows,linux}/` for network/bluetooth/peripheral/central; the `bluetooth` module is `cfg`-selected per-OS in `lib.rs`. Key files: `lib.rs` (`start_transfer` entry point), `discovery.rs`, `noise.rs`, `sending.rs`/`receiving.rs`.\n- `Flying Carpet/` — Tauri desktop app. Rust backend in `Flying Carpet/src-tauri/` (workspace member), JS/HTML frontend in `Flying Carpet/src/` (`main.js`, `index.html`). **Note the space in the directory name** — quote it in shell commands.\n- `Android/FlyingCarpet/` — Android app (Kotlin). Noise/discovery ports in `app/src/main/java/dev/spiegl/flyingcarpet/`.\n- `Apple/` — iOS + macOS apps (Swift). Protocol code shared by both in `Apple/shared/`, per-platform UI in `Apple/iOS/` and `Apple/macOS/`. **Read `Apple/CLAUDE.md` before changing anything under it** — the Apple platforms have constraints the others don't (no programmatic hotspot, so Apple-to-Apple requires shared network mode; BLE can't pair iPhone↔Mac).\n- `docs/` — design docs (see below). `ARCHITECTURE.md` — connection role model.\n\n## Build & test\n\nRust is a Cargo **workspace** (`core` + `Flying Carpet/src-tauri`):\n\n- `cargo test` — run all Rust tests (includes the Noise/discovery known-answer vectors in `core/src/noise.rs` and `core/src/discovery.rs`). `cargo build` to compile.\n- Desktop app: `cargo tauri dev` (run) / `cargo tauri build` (release). Needs the Tauri CLI and the Linux deps listed in `README.md`.\n- Android: from `Android/FlyingCarpet/`, `./gradlew assembleDebug` / `./gradlew test`. **Set `JAVA_HOME` to the Android Studio JBR** (the bundled JDK) or Gradle fails.\n- Apple: **macOS only**, and outside the Cargo workspace — `cargo build` never touches it. From `Apple/`, `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project <iOS|macOS>/FlyingCarpet.xcodeproj -scheme \"FlyingCarpet\" build`. `DEVELOPMENT_TEAM` is blank in both projects, so signing fails until you set your team in Xcode or pass `DEVELOPMENT_TEAM=YOURTEAMID`. Details in `Apple/CLAUDE.md`.\n\n## Architecture & design docs — read before changing these areas\n\n- **`ARCHITECTURE.md`** — the four independent role axes (transfer direction / BLE peripheral-central / hotspot host-joiner / TCP server-client) and how they map per platform pair. Read before touching connection setup, hosting logic, or BLE.\n- **`docs/shared-network-crypto.md`** — the full v10 cryptographic design (the normative reference for the handshake bytes). Read before touching anything crypto, discovery-auth, or the record/framing layer.\n- **`docs/bluetooth-field-guide.md`** — **read before touching any BLE code on any platform.** The four independent axes (advertising / scanning / bonding / GATT services), seven hard-won laws, a per-platform matrix, and a symptom→cause playbook. Bluetooth bugs here are subtle, intermittent, and platform-asymmetric; several have been re-derived from scratch more than once. Chronological investigation logs: `docs/windows-ble-gatt-0x8000ffff.md`, `docs/ble-bond-asymmetries.md`.\n\n## Load-bearing invariants (don't \"simplify\" these)\n\n- **v10 = Noise.** Every transfer (both modes) runs a `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake; the PSK is `PBKDF2-HMAC-SHA256(password, salt=\"Flying Carpet v10 shared network PSK\", 600_000)`. Noise is the **sole** cipher — the old inner per-chunk AES is gone. v10 is a clean break; v9 peers are rejected. If v10 ships before a later Noise wire change, that change must bump to v11.\n- **Preamble → prologue binding.** Version/mode are negotiated in a plaintext preamble, then every preamble byte is bound into the Noise prologue. Both platforms of any pair must build the prologue identically (`build_prologue`/`buildPrologue`). Cross-platform KATs guard this — keep all three in sync (Rust `core/src/noise.rs`, Kotlin `NoiseUnitTest`, Swift `Apple/macOS/FlyingCarpetTests/FlyingCarpetTests.swift`; discovery vector: `core/src/discovery.rs` `test_cross_platform_vector` == Android `DiscoveryUnitTest.kt` == the Swift discovery test in the same file). Note the Swift KATs run only under `xcodebuild` on a Mac, so `cargo test` and `./gradlew test` passing is **not** evidence the Swift side still agrees.\n- **Passwords: single-use + CSPRNG.** The receiver mints a fresh random password per transfer and displays it; never reuse, never user-chosen, never \"remember.\" The entire \"offline crack is worthless\" security argument depends on this (see the crypto doc §7). The discovery HMAC key is derived from the stretched PSK, so no fast hash of the password goes on the wire.\n- **Bluetooth is hotspot-only.** Shared network mode exchanges the password manually (display + type/QR); do **not** re-add BLE to shared mode. Apple-to-Apple can't pair iPhone↔Mac over BLE by design, which is exactly the pair that would need it. Rationale is recorded in `ARCHITECTURE.md` (\"Bluetooth + Shared Network Mode\"). Every platform greys out its BT switch in shared network mode; on Apple that goes through `bluetoothSwitchShouldBeEnabled()`, since restoring the switch on hardware capability alone re-enabled it after a transfer.\n- **Receiver is the anchor.** In both modes the receiver generates the password and is the TCP server (Noise responder); the sender is the TCP client (Noise initiator).\n\n## Conventions & gotchas\n\n- Android: keep `res/layout/` and `res/layout-land/` in sync when changing the UI.\n- `core/Cargo.toml` is pinned to **LF** line endings (`.gitattributes`); don't let an editor rewrite it to CRLF.\n- Header-value bounds (file count / filename length / chunk size sanity checks) and filename sanitization apply to values read from the **Noise-decrypted** stream, not the raw socket.\n</yakaai_skills>"}