ARCHITECTURE.md
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:
core/src/linux/bluetooth.rs — Mode::Send → peripheral branch (line 85), else → central branch (line 156)core/src/windows/bluetooth.rs — Mode::Send → peripheral branch (line 74), else → central branch (line 144)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.
is_hosting(peer, mode)| 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
is_hosting(peer, mode)| 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
isHosting()| 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.
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:
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.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:
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:
core/src/lib.rs start_tcp() — PeerResource::WifiClient → connect, otherwise → bind+acceptMainViewModel.kt startTCP() — isHosting() → ServerSocket(3290).accept(), else → Socket(peerIP, 3290)The hotspot host generates the password. It is shared with the peer via one of:
The desktop app's needPassword() function:
"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:
239.255.73.67:3290) and unicast subnet scan| 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.
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.