This guide shows how to integrate the Offline Protocol SDK into a native iOS app (Swift).
- Xcode 15+
- Rust toolchain with iOS targets
rustup target add aarch64-apple-ios
rustup target add x86_64-apple-ios # For simulator
rustup target add aarch64-apple-ios-sim # For M1 simulatorcd crates/offline-protocol-uniffi
# Build for iOS devices
cargo build --release --target aarch64-apple-ios
# Build for simulator
cargo build --release --target aarch64-apple-ios-simEach build produces liboffline_protocol_uniffi.a under target/<triple>/release/
(the crate's [lib] name is offline_protocol_uniffi).
The recommended path is the helper script, which builds the device/simulator slices and
regenerates the UniFFI bindings — all three languages, not just Swift. They are one artifact
set off one UDL, so the script also rewrites the Kotlin and Python bindings; commit all three
together (see scripts/generate-bindings.sh):
# From bindings/react-native
./scripts/build-uniffi-ios.shIt produces, under bindings/react-native/ios/:
libs/offline_protocol_uniffi.xcframework, holding two slices:ios-arm64(device) andios-arm64_x86_64-simulator(simulator, fat)Generated/offline_protocol.swiftandGenerated/offline_protocolFFI.modulemap
Add the XCFramework plus offline_protocol.swift to your Xcode project. Xcode
selects the slice matching the SDK you build against, so the same project
configuration serves device and simulator with no per-target linker flags —
device and simulator arm64 cannot coexist in one lipo archive, which is why
this is an XCFramework rather than a single fat .a.
To do it by hand instead: lipo-combine the two simulator per-arch
liboffline_protocol_uniffi.a outputs, leave the device one alone, give both
files the same basename in separate staging directories, and pass each with
-library to xcodebuild -create-xcframework. For the Swift bindings run
./scripts/generate-bindings.sh — never uniffi-bindgen directly. It emits
Swift, Kotlin and Python together because they are one artifact set off one
UDL, and a subset leaves the rest describing a different ABI, which fails at
the app's first FFI call rather than at build time.
The generated offline_protocol.swift declares the public types (OfflineProtocol,
ProtocolConfig, EventCallback, MessagePriority, …). When it compiles into your app
target no extra import is needed — the low-level FFI is exposed to it as the
offline_protocolFFI clang module referenced from the generated file.
import Foundation
// Events are delivered as JSON strings — implement `EventCallback` to receive them.
final class MeshEventHandler: EventCallback {
func onEvent(eventJson: String) {
guard let data = eventJson.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String else { return }
switch type {
case "message_received":
let sender = obj["sender"] as? String ?? "?"
let content = obj["content"] as? String ?? ""
print("Received from \(sender): \(content)")
case "transport_switched":
print("Transport switched: \(eventJson)")
default:
break
}
}
}
final class MeshController {
private var offlineProtocol: OfflineProtocol?
private let eventHandler = MeshEventHandler()
func startMesh() {
// ProtocolConfig has 16 required fields (+ 8 with defaults). See
// docs/configuration.md for what each one controls.
let config = ProtocolConfig(
appId: "my-ios-app",
profile: "user123",
bleEnabled: true,
wifiDirectEnabled: false, // the peer-stream slot; TCP over a LAN or AWDL on iOS
internetEnabled: true,
reticulumEnabled: false,
nostrEnabled: false,
preferOnline: false,
initialTtl: 8,
encryptionEnabled: true,
autoKeyExchange: true,
storePending: true,
maxPendingPerPeer: 100,
maxPendingGlobal: 1000,
pendingTtlMs: 86_400_000, // 24 h (the SDK default)
overflowPolicy: .dropOldest
// These 9 use their defaults: requireEncryption (true),
// maxGroupMembers (256), groupRelayEnabled (true),
// groupRelayBroadcastEnabled (false — group sends fan out per
// member so each copy gets the full delivery ladder; see
// docs/configuration.md#group-configuration),
// requireTransportIdentity (false), binaryWireEnabled (true),
// compactEnvelopeEnabled (true), richPayloadEnabled (true),
// cryptoRecoveryEnabled (true).
)
do {
let mesh = try OfflineProtocol(config: config)
// Install the callback BEFORE start(): restore settlements from the
// previous run are parked and drained on start(), so anything
// emitted before the callback exists is lost.
mesh.setEventCallback(callback: eventHandler)
// REQUIRED before you can send anything. Encryption is fail-closed
// by default, so with MLS uninitialized every send fails with
// encryptFailed. Unlike React Native there is no auto-initialization
// on the native path — you supply both providers yourself.
try mesh.initializeMls(
secureStorage: KeychainMlsStorage(), // credential-backed
protocolStateStorage: AppContainerStateStorage() // in the app container
)
try mesh.start()
// Send a message (priority is required; replyToMsg is optional)
let messageId = try mesh.sendMessage(
recipient: "user456",
content: "Hello from iOS!",
priority: .high,
replyToMsg: nil
)
print("Message sent: \(messageId)")
offlineProtocol = mesh
} catch {
print("Error: \(error)")
}
}
func stopMesh() {
try? offlineProtocol?.stop()
}
}initializeMls takes two providers because key material and restartable delivery
state have different lifetimes. KeychainMlsStorage and
AppContainerStateStorage above are your classes — the SDK ships no default
for the native path.
MlsStorageProvider |
ProtocolStateStorageProvider |
|
|---|---|---|
| Holds | MLS identity, sessions, groups, peer trust records, install secrets, the record-sealing key | Outbox, pending messages, session/Welcome lifecycles, peer snapshots, media descriptors, block list, Lamport clock |
| Back it with | Keychain | Application Support, with isExcludedFromBackup = true — must be removed when the app is deleted |
| Value type | [UInt8] (sequence<u8>) |
Data (bytes) |
The Keychain can outlive an app container, which is why delivery state must not
live in it: deleting the app would otherwise leave queued message plaintext and
cloud-media encryption_key/iv values in the Keychain with nothing that ever
reads or deletes them. Sensitive state-record values are sealed with a
per-install AEAD key held in the secure provider, so the state provider only ever
sees ciphertext.
Two obligations bite hardest on iOS. Writes must be durable before store
returns — the SDK treats a successful store as persisted and immediately writes
state that depends on it, so fsync the file and its parent directory
(F_FULLFSYNC), including on delete. And bound your reads: stat the entry
first and never hand back more than 8 MiB
(MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES); by the time the SDK can check a
length it has already allocated the bytes.
The React Native module's ProtocolStateStorage.swift and
StorageNamespace.swift are working reference implementations. Read the
custom-provider contract before
writing your own; every obligation there exists because something breaks on a
device without it.
initializeMls is transactional — a failed call rolls back and leaves no partial
state, so surface the error and retry rather than proceeding. Do not treat a
failure as "start clean": a blocked_users listing failure deliberately fails
initialization rather than coming up with every peer unblocked.
See MLS Integration for the full provider interfaces.
Add to Info.plist:
<!-- Bluetooth permissions -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to communicate with nearby devices</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to communicate with nearby devices</string>
<!-- Background modes (REQUIRED for reliable BLE operation) -->
<!-- Without these, iOS will throttle/stop BLE scanning and advertising -->
<!-- causing missed discoveries and false "peer lost" events -->
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>The SDK ships as an npm package for React Native apps:
npm install @offline-protocol/mesh-sdkFor a native iOS app (no React Native), integrate the Rust core directly via the
manual XCFramework build in steps 1–4 above: build the offline-protocol-uniffi static
libraries for the device and simulator targets, package them into an XCFramework, and add
it to your Xcode project alongside the UniFFI-generated Swift bindings.
A standalone CocoaPods pod and a Swift Package Manager manifest are not currently published — use the npm module (React Native) or the manual XCFramework path (native).
iOS App (Swift)
↓
UniFFI Generated Bindings (Swift)
↓
Rust Core (100% safe)
Create and manage encrypted groups over the mesh. The group creator is automatically an admin. The snippets below assume you've unwrapped your started instance into a non-optional offlineProtocol (e.g. guard let offlineProtocol = self.offlineProtocol else { return }).
// Create a group
let group = try offlineProtocol.createGroup(groupName: "Project Team")
// Invite a member (admin only)
try offlineProtocol.inviteToGroup(groupId: group.groupId, inviteeUserId: "bob")
// Send an encrypted group message
let messageIds = try offlineProtocol.sendGroupMessage(
groupId: group.groupId,
content: "Hello team!",
priority: nil,
replyToMsg: nil
)
// Remove a member (admin only)
try offlineProtocol.removeFromGroup(groupId: group.groupId, memberId: "bob")
// Get group info (members, epoch, etc.)
if let info = try offlineProtocol.getGroupInfo(groupId: group.groupId) {
print("Members: \(info.members)")
}
// Rename a group (admin only)
try offlineProtocol.renameGroup(groupId: group.groupId, newName: "New Team Name")
// Leave a group
try offlineProtocol.leaveGroup(groupId: group.groupId)Groups use role-based access control: Admin and Member.
// Promote a member to admin (admin only)
try offlineProtocol.setMemberRole(groupId: groupId, userId: "bob", role: "admin")
// Check a member's role
let role = try offlineProtocol.getMemberRole(groupId: groupId, userId: "bob") // "admin" or "member"
// Get all roles
let roles = try offlineProtocol.getGroupRoles(groupId: groupId)
// ["alice": "admin", "bob": "admin", "charlie": "member"]Role changes and renames arrive through the same EventCallback as every other event —
JSON strings whose type is group_role_changed or group_renamed. Handle them inside
your onEvent(eventJson:):
func onEvent(eventJson: String) {
guard let data = eventJson.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String else { return }
switch type {
case "group_role_changed":
print("\(obj["user_id"] ?? "?") is now \(obj["new_role"] ?? "?") (by \(obj["changed_by"] ?? "?"))")
case "group_renamed":
print("Group \(obj["group_id"] ?? "?") renamed to \(obj["new_name"] ?? "?") by \(obj["renamed_by"] ?? "?")")
default:
break
}
}Security invariants:
- Only admins can call invite, remove, change-role, or rename — these are checked before sending
- Role changes and renames are additionally enforced on receive: a non-admin's frame is rejected by every peer
- Membership changes (invite/remove) are not enforced on receive. MLS authenticates the committer as a group member, but a member running a modified client can add or remove anyone; the change applies and is reported via
group_unauthorized_membership_change. See Group authorization model - The last admin cannot be demoted, removed, or leave (prevents orphaned groups)
- If the last admin disconnects unexpectedly, a deterministic election promotes the next admin
Shared state any member of a space can edit while disconnected, merging deterministically when the replicas meet again. The model, how concurrent edits resolve, and the limits are in the Replicated Documents guide; this section is the Swift shape of it.
DataStore wraps a live protocol instance. It needs initializeMls to have
run, because documents are sealed at rest with the record key that call mints.
data.enabled defaults to true, so nothing switches the layer on.
let store = try DataStore(protocol: offlineProtocol)
// A space is an MLS scope: a peer's address for a 1:1 space, a group id for a
// group. Values cross as JSON: {"kind":"text","value":"..."}.
let space = peerAddress
try store.createDoc(spaceId: space, docId: "trip")
try store.mapSet(
spaceId: space, docId: "trip", collection: "meta", key: "title",
valueJson: #"{"kind":"text","value":"Coast road"}"#
)
try store.textInsert(
spaceId: space, docId: "trip", collection: "notes",
position: 0, text: "Meet at the bridge"
)
try store.counterIncrement(
spaceId: space, docId: "trip", collection: "opened", amount: 1
)
// Edits batch. This is what makes them durable and sends them to peers.
try store.flush(spaceId: space, docId: "trip")
let json = try store.docJson(spaceId: space, docId: "trip")To put documents in a store of your own rather than the one protocol state
already uses, construct it with a provider instead. Sealing sits above that
seam, so the adapter is handed sealed bytes and never sees document content,
and an app that does this owes wipeAll() on logout because
wipePersistedState cannot reach a backend it does not know about:
let store = try DataStore.withStorage(protocol: offlineProtocol, storage: myBackend)A reference implementation and the conformance suite that gates one are in
examples/storage-adapters/swift/.
Six events arrive through the same EventCallback as everything else. Handle
data_changed to re-render (it fires after the change is durable), and
data_attachment_requested if your app writes attachment references: the SDK
never kept the blob bytes, so only your app can answer, and a request nobody
answers leaves the asking side showing a spinner forever.
case "data_changed":
reload(space: obj["space_id"] as? String, doc: obj["doc_id"] as? String)
case "data_attachment_requested":
// Answer with provideAttachment(...), or declineAttachment(...) if the
// bytes are gone. Both are real answers; silence is not.
breakThere is no trust pin to manage. A peer's address is the hash of their
identity key, so every control message they send is checked by re-deriving the
address from the key that signed it — on first contact as much as on the
thousandth. An impersonator has to find a 160-bit second preimage (~2^160), not
win a race to a name. (That is the cost of targeting an address that already
exists. The birthday bound on the same truncation is ~2^80, which yields two
keys sharing one address rather than a chosen peer's — a deliberate trade
against BLE frame budget, documented on Address::HASH_LEN.)
That also removes the reinstall problem the old resetTofuForPeer existed for:
a peer who reinstalls generates a new identity key and therefore has a new
address. They reach you as a new contact, not as the old one behaving oddly,
and there is nothing to reset. If you see
SENDER_ADDRESS_MISMATCH, treat it as an impersonation attempt — it has no
benign reading.
iOS has no Wi-Fi Direct API. The wifiDirect transport slot is the
peer-stream slot, and on iOS it is filled by TCP
streams over Network framework, on the local network or, with no shared
network, over AWDL (Apple's peer-to-peer Wi-Fi). Each peer proves its address
with the identity preamble before anything it sends reaches the protocol, and
a peer that does not prove one is disconnected. To enable it, list the
service type in Info.plist and describe the local-network use, or iOS
local-network privacy blocks discovery; the SDK reports a denial as an
error diagnostic:
<key>NSBonjourServices</key>
<array>
<string>_offlineprotocol._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Finds and talks to nearby devices running this app</string>The slot reaches other iPhones and, on a shared network, hosts running the
Python binding's PeerStreamManager. It does not interoperate with
Android's Wi-Fi Direct, and an SDK from 0.27 or earlier, which used
MultipeerConnectivity, does not see it. Available transports:
- Bluetooth Low Energy
- Internet
- Reticulum and Nostr
- The peer-stream slot through Network framework
- Message sending: <1ms overhead
- Memory safe: Zero buffer overflows or memory leaks
- Battery efficient: Optimized for iOS power management