Skip to content

Latest commit

 

History

History
457 lines (365 loc) · 18.1 KB

File metadata and controls

457 lines (365 loc) · 18.1 KB

iOS Integration Guide

This guide shows how to integrate the Offline Protocol SDK into a native iOS app (Swift).

Prerequisites

  • Xcode 15+
  • Rust toolchain with iOS targets

Setup

1. Install Rust 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 simulator

2. Build Rust Library

cd 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-sim

Each build produces liboffline_protocol_uniffi.a under target/<triple>/release/ (the crate's [lib] name is offline_protocol_uniffi).

3. Package the Library and Generate Bindings

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.sh

It produces, under bindings/react-native/ios/:

  • libs/offline_protocol_uniffi.xcframework, holding two slices: ios-arm64 (device) and ios-arm64_x86_64-simulator (simulator, fat)
  • Generated/offline_protocol.swift and Generated/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.

4. Use in Swift

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()
    }
}

5. Storage: Two Providers, Two Lifecycles

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.

Permissions

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>

Distribution

The SDK ships as an npm package for React Native apps:

npm install @offline-protocol/mesh-sdk

For 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).

Architecture

iOS App (Swift)
    ↓
UniFFI Generated Bindings (Swift)
    ↓
Rust Core (100% safe)

Group Messaging (MLS-Encrypted Mesh)

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)

Group Role Management

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

Replicated Documents

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.
    break

Peer Identity

There 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.

Platform Limitations

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

Performance

  • Message sending: <1ms overhead
  • Memory safe: Zero buffer overflows or memory leaks
  • Battery efficient: Optimized for iOS power management