Skip to content

exponential-net-ffi (Swift and Kotlin)

The UniFFI 0.31 facade over enet-core for iOS, macOS and Android: an xcframework / an Android .so plus the generated bindings. License: MIT OR Apache-2.0.

  • EnetNode.start(config, identity) (async; identity = 32 bytes from generateIdentity(), kept in the keychain). defaultConfig() = our relay (https://relay.exponential.at), n0 relays off, nobody allowed. The facade owns one tokio runtime per process (2 worker threads, enet); every async method runs there and is awaited from Swift concurrency / Kotlin coroutines.
  • Addressing and access: endpointId(), ticket() (enet1…, for a QR code), addTicket(ticket) (→ the peer’s id), allowPeer / revokePeer / allowedPeers, setOpen(bool) (pairing window), setAccessPolicy(policy?) (foreign EnetAccessPolicy.allow(peer, alpn)), online(timeoutMs).
  • Events: a foreign EnetListener.onEvent(event) (setListener, called on an eNet thread) OR await nextEvent() in a loop; one per node. EnetEvent = chat {from, message}, record {record}, callIncoming {call, from}, callAccepted {call}, callFrame {call, seq, data}, callEnded {call, reason: local|remote|failed: …}, peerRefused {peer, alpn}.
  • Chat: sendChat(peer, body) → EnetChatMessage {id, timestampMs, body} once the peer acknowledged it.
  • Files: blobAdd(data), blobAddPath(path) → EnetBlob {hash, hashHex, size, ticket}; blobGet(hash), blobFetch(peer, hash), blobFetchTicket(ticket). EnetConfig.blobDir persists blobs (feature fs-store, default on).
  • Records: joinSpace(space[32], bootstrap) (= subscribe: records arrive as record events), putRecord(space, key, value), getRecord, records, leaveSpace.
  • Calls: startCall(peer) → call id, acceptCall, sendFrame(call, opus) → seq, hangUp.
  • shutdown().
  • Free functions: generateIdentity, endpointIdOf, identitySign(identity, message) (64-byte ed25519; the install registration signature, vector in packages/domain-contract/fixtures/install-register.json), parseTicket, pushKeypairDerive(identity) / pushKeypairGenerate ({secret, public}), pushSeal(recipientPublic, plaintext), and pushOpen(recipientSecret, sealed) for the iOS Notification Service Extension / the Android FCM service: no node, no runtime, only the X25519 secret (byte format in the core README).
  • Sealed session content (EXP-1458, spec + vectors in packages/domain-contract/fixtures/sealed-content.json): generateTreeKey ({secret, publicKey}), wrapTreeKey(installPublic, treeSecret) / unwrapTreeKey(installSecret, wrapped), sealBody(treePublicKey, epoch, text) / openBody(body, keys), sealCard(treePublicKey, epoch, cardJson) / openCard(cardJson, keys); keys = [EnetEpochKey {epoch, secret}], results carry an EnetSealStatus (plain | opened | noKey | unsupported | malformed | refused).
  • Errors: EnetError = Invalid | Bind | Connect | Refused | Crypto | NotFound | Transport | Closed | Panic, each with a reason. Async work runs in spawned tasks, so a Rust panic comes back as Panic instead of crossing the FFI.
Terminal window
bash apps/desktop/crates/exponential-net-ffi/generate-bindings.sh # bindings/swift + bindings/kotlin from the host library
bash apps/desktop/crates/exponential-net-ffi/build-ios.sh # out/ExponentialNetFFI.xcframework (+ bindings)
bash apps/desktop/crates/exponential-net-ffi/build-android.sh # out/jniLibs/<abi>/libexponential_net_ffi.so (+ bindings)
bash apps/desktop/crates/exponential-net-ffi/run-binding-tests.sh # the Swift suite against the host dylib

ENET_PUSH_ONLY=1 on any of the three scripts builds the facade with --no-default-features: enet-core without its node feature, so only pushOpen, pushSeal, pushKeypairGenerate/Derive, the sealed-content functions and EnetError remain, on RustCrypto alone (no iroh, no tokio). It is what the iOS Notification Service Extension links (~24 MB memory cap): bindings under bindings/push/swift (committed), artifacts under out/push/ (ExponentialNetPush.xcframework). Android links the FULL node instead (VAPP-40): its FCM service runs in the app process, so one .so serves both the nodes and pushOpen; the app’s buildEnetFfi gradle task runs build-android.sh before merge*JniLibFolders. The push build’s Swift names differ from the full build’s (uniffi-push.toml): module ExponentialNetPush, C module ExponentialNetPushFFI, library libexponential_net_push.a, so the iOS app links both side by side (VAPP-40: ExpCore, a dynamic framework, holds the full node; the Notification Service Extension and ExpPush the push build) without two xcframeworks writing the same build product. tests/push_fixture.rs replays packages/domain-contract/fixtures/push-seal.json in both builds (cargo test -p exponential-net-ffi --no-default-features).

Device builds use the workspace’s mobile profile (LTO, one codegen unit, stripped), one crate type per build so LTO applies. The generated binding sources under bindings/ are COMMITTED (the contract, reviewable without a toolchain); out/ is ignored. Android needs the NDK (ANDROID_NDK_HOME or ~/Library/Android/sdk/ndk/*): ring, iroh’s TLS crypto, compiles C, so a bare cargo check --target aarch64-linux-android fails without the NDK clang; cargo ndk -t arm64-v8a check -p exponential-net-ffi works. Kotlin uses JNA (≥ 5.12), like exponential-ui-ffi.

tests/facade.rs drives two nodes over localhost from Rust (chat, records, blobs, a call, a listener, refusal by a foreign policy, push sealing); tests/swift/main.swift does chat, records and push sealing through the Swift binding.