A 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.
| Axis | Role A | Role B |
|---|---|---|
| Transfer direction | Sender | Receiver |
| BLE | Peripheral (advertiser) | Central (scanner) |
| WiFi hotspot | Host | Joiner |
| TCP | Server (listener) | Client (connector) |
These four axes are not all aligned — the mapping depends on the platform pair and connection mode.
BLE role is determined solely by transfer direction, the same on every platform:
| Transfer Direction | BLE Role |
|---|---|
| Sender | Peripheral — advertises GATT service, exposes characteristics |
| Receiver | Central — scans for peripheral, connects, reads/writes characteristics |
Source:
- Linux:
core/src/linux/bluetooth.rs—Mode::Send→ peripheral branch (line 85),else→ central branch (line 156) - Windows:
core/src/windows/bluetooth.rs—Mode::Send→ peripheral branch (line 74),else→ central branch (line 144) - Android:
MainActivity.kt—Mode.Sending→advertise(),Mode.Receiving→scan()(lines 83-88)
The GATT service exposes three characteristics: OS, SSID, Password.
Data flow direction depends on who is hosting the hotspot, not who is the BLE peripheral/central:
| Hosting device is... | SSID/Password flow |
|---|---|
| Peripheral (sender is hosting) | Peripheral populates GATT; central reads |
| Central (receiver is hosting) | Central writes SSID/password to peripheral's GATT |
The OS characteristic always flows both ways: each side needs to know the other's OS.
The 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.
| Peer OS | Linux Hosts? |
|---|---|
| Android | Always |
| iOS | Always |
| macOS | Always |
| Windows | Never |
| Linux | Only if Receiving |
Source: core/src/linux/network.rs lines 11-20
| Peer OS | Windows Hosts? |
|---|---|
| Android | Always |
| iOS | Always |
| macOS | Always |
| Linux | Always |
| Windows | Only if Receiving |
Source: core/src/windows/network.rs lines 674-683
| Peer OS | Android Hosts? |
|---|---|
| iOS | Always |
| macOS | Always |
| Android | Only if Receiving |
| Linux | Never (Linux hosts) |
| Windows | Never (Windows hosts) |
Source: MainViewModel.kt lines 112-116
iOS and macOS never host hotspots — they lack a public hotspot API. The peer always hosts.
Peer OS Selection — why it exists, and when it's hidden
The "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.
There are three ways to learn the peer's OS, and the UI control is only the third:
- Shared network mode — nobody hosts, so
is_hostingis 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. - 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.
- 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.
Every 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).
Beyond the hosting decision, knowing the peer OS in hotspot-without-BT also drives two secondary behaviors:
- 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.
- 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.
Not 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.
TCP role follows directly from hotspot role:
| Hotspot Role | TCP Role |
|---|---|
| Host | Server — binds 0.0.0.0:3290, calls accept() |
| Joiner | Client — connects to host's gateway IP on port 3290 |
Source:
- Rust:
core/src/lib.rsstart_tcp()—PeerResource::WifiClient→ connect, otherwise → bind+accept - Android:
MainViewModel.ktstartTCP()—isHosting()→ServerSocket(3290).accept(), else →Socket(peerIP, 3290)
The hotspot host generates the password. It is shared with the peer via one of:
- Bluetooth — delivered through GATT characteristics (direction depends on who's hosting, see BLE Data Flow above)
- QR code — host displays QR code, joiner scans it (for mobile peers)
- Manual entry — host displays password as text, joiner types it in
The desktop app's needPassword() function:
- Returns false → this device is hosting → generates and displays the password
- Returns true → this device is joining → user must enter the host's password
"A → B" means A is sending to B.
| Scenario | BLE: Peripheral | BLE: Central | Hotspot Host | TCP Server | Password Generator |
|---|---|---|---|---|---|
| Linux → Android | Linux | Android | Linux | Linux | Linux |
| Android → Linux | Android | Linux | Linux | Linux | Linux |
| Linux → Windows | Linux | Windows | Windows | Windows | Windows |
| Windows → Linux | Windows | Linux | Windows | Windows | Windows |
| Linux → iOS/macOS | Linux | iOS/macOS | Linux | Linux | Linux |
| Windows → iOS/macOS | Windows | iOS/macOS | Windows | Windows | Windows |
| Android → iOS/macOS | Android | iOS/macOS | Android | Android | Android |
| Linux → Linux | Sender | Receiver | Receiver | Receiver | Receiver |
| Windows → Windows | Sender | Receiver | Receiver | Receiver | Receiver |
| Android → Android | Sender | Receiver | Receiver | Receiver | Receiver |
| Windows → Android | Windows | Android | Windows | Windows | Windows |
| Android → Windows | Android | Windows | Windows | Windows | Windows |
Key pattern: Hotspot Host = TCP Server = Password Generator. BLE Peripheral = Sender, BLE Central = Receiver. These two groupings are independent.
In 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.
No hotspot is created. Both devices are already on the same LAN.
Both devices simultaneously:
- Send HMAC-signed announcements via UDP multicast (
239.255.73.67:3290) and unicast subnet scan - Listen for announcements from the peer
- Validate: magic bytes, HMAC (using password-derived key), timestamp window, opposite role
| Transfer Direction | TCP Role |
|---|---|
| Receiver | Server — binds TCP listener on port 3290 before discovery starts |
| Sender | Client — connects to receiver's IP after discovery completes |
This is consistent with same-platform hotspot mode, where the receiver always hosts and is the TCP server.
- Receiver generates the password and displays it
- Sender enters the password manually
Consistent with hotspot mode: the receiver is always the "anchor" role (host in hotspot, server + password generator in shared network).
The 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.
The 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.
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.
Rationale. 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.
Every 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.