Thin Swift shell over the Rust TrUAPI core (UniFFI). Wire decoding, request routing, and subscription lifecycle stay in the Rust core; products connect through the localhost WebSocket bridge.
The Rust core itself lives in paritytech/truapi; this repo carries only the Swift package, so SPM resolution never touches the truapi repo or its submodules. The build scripts regenerate the committed outputs from a truapi checkout (a sibling ../truapi by default, override with TRUAPI_ROOT).
The TrUAPIHost SPM package an iOS host app imports directly. It carries:
Sources/TrUAPIHost/TrUAPIHost.swift— the hand-written shell:TrUAPIHostCore(owning wrapper around the UniFFI-generatedNativeTrUApiCore, with the localhost WS bridge, session controls, and native change notifications),TrUAPIHostCoreProtocol,RuntimeConfig, andLocalhostBridgeBootstrap.- the Rust core as a binary target — a GitHub release asset by default (
publishedBinaryURLinPackage.swift), or the locally builtBinaries/truapi_server.xcframeworkwhenuseLocalBinaryis flipped to true. Sources/TrUAPIHost/truapi_server.swiftandSources/truapi_serverFFI/include/— the generated UniFFI bindings.container/— the TS lockdown container; built intoSources/TrUAPIHost/Resources/truapi-container.jsand exposed viaContainerScriptBundle.load().Tests/— WS-bridge round-trip tests that boot the real Rust core.
The generated bindings and the container bundle are committed build outputs; the xcframework is gitignored and distributed as a GitHub release asset. Two scripts split the lifecycle:
./scripts/rebuild.sh # regenerate xcframework + bindings + container
# from the truapi checkout (TRUAPI_ROOT)
./scripts/publish.sh <version> # zip the built xcframework, upload it to the
# "v<version>" GitHub release on this repo,
# commit + push the Package.swift bump
# (URL + checksum), and move the tag onto
# that commitRun rebuild.sh after changing anything host-visible — the NativeTrUApiCore methods, HostCallbacks, the native mirror types in truapi's rust/crates/truapi-server/src/native*, or container/src — and commit the regenerated bindings/container together with the source change. When the binary should reach consumers, run publish.sh from the branch to release; it enforces the safe ordering itself (asset live before the manifest referencing it is pushed, tag moved onto the manifest commit so semver pins resolve a manifest whose asset exists).
For local iteration without publishing, flip useLocalBinary = true in Package.swift to build against Binaries/ directly; flip it back before committing.
The embedding app implements the UniFFI-generated HostCallbacks protocol directly (defined in truapi_server.swift): navigation, push, permissions, auth state, scoped + core storage, chain JSON-RPC, confirmations, preimage, theme, and feature support. UI-decision callbacks are async and awaited by the Rust core.
Add the package as an SPM dependency and link the TrUAPIHost product into the app target:
.package(url: "https://github.com/paritytech/truapi-ios.git", from: "0.1.0").product(name: "TrUAPIHost", package: "truapi-ios")Releases are the v<version> tags cut by scripts/publish.sh; each tag's manifest references its own uploaded xcframework, so semver pinning is safe. To track unreleased work instead, depend on branch: "main". Either way SPM pins the resolved revision in the app's Package.resolved; update it (File > Packages > Update in Xcode, or xcodebuild -resolvePackageDependencies) after a new release or new commits on the branch.
Run the package tests against an iOS simulator (the xcframework has no macOS slice):
# from the repo root
xcodebuild test -scheme TrUAPIHost -destination 'platform=iOS Simulator,name=iPhone 16'product app in WKWebView
Uint8Array frames via @parity/truapi createWebSocketProvider
|
v ws://127.0.0.1:<port>/?t=<token>
TrUAPIHostCore.startWsBridge()
→ libtruapi_server (tokio WS server)
→ Rust dispatcher
The product running in the WKWebView opens a WebSocket to the localhost port + token returned by startWsBridge. From there the Rust core handles the wire protocol directly. Outbound responses and host-side capability callbacks (navigateTo, pushNotification, cancelNotification, devicePermission, remotePermission, authStateChanged, core storage, chain JSON-RPC, confirmations, preimage, theme, featureSupported, storage) reach the embedder through HostCallbacks.
The core's Permissions platform trait has two methods, and so does HostCallbacks:
devicePermission(request:)- OS-scoped grants (camera, mic, location, push).requestis a typedNativeDevicePermission.remotePermission(request:)- per-product capabilities.requestis a typedNativeRemotePermission.
Both return a Bool granted flag; the host renders the typed request in its own prompt UI. The same typed values drive the TrUAPIHostCore permission admin API (permissionAuthorizationStatus, setPermissionAuthorizationStatus), which reads and updates the persisted decisions without prompting.
Threading: the Rust core invokes every
HostCallbacksmethod on a background thread it owns, never the main thread. Hop to the main thread (MainActor/DispatchQueue.main) before touching UIKit, WebKit, or theWKWebView. Theasynccallbacks (navigateTo,pushNotification,devicePermission,remotePermission,featureSupported,confirmUserAction,lookupPreimage) are awaited by the core, so an implementation may suspend for as long as the user takes to decide (e.g.await MainActor.run { ... }or anwithCheckedContinuationaround a prompt); other TrUAPI traffic keeps flowing while you wait. The remaining sync callbacks (auth state, storage, core storage, chain, theme,cancelNotification) run inline on the dispatcher thread and must return promptly without blocking.
import Foundation
import WebKit
import TrUAPIHost
final class MyCallbacks: HostCallbacks, @unchecked Sendable {
private var storage: [String: Data] = [:]
private var coreStorage: [Data: Data] = [:]
func onCoreLog(marker: String, detail: String) { /* log */ }
func navigateTo(url: String) async throws {
await MainActor.run { /* UIApplication.shared.open(...) */ }
}
func pushNotification(request: PushNotificationRequest) async throws -> UInt32 {
let id: UInt32 = 1
await MainActor.run { /* schedule request.text / request.deeplink / request.scheduledAt */ }
return id
}
func cancelNotification(id: UInt32) throws {
DispatchQueue.main.async { /* cancel notification */ }
}
func devicePermission(request: NativeDevicePermission) async throws -> Bool {
// Awaited by the core: present the prompt and suspend until the user
// decides. Other TrUAPI traffic keeps flowing while suspended.
await MainActor.run { /* show prompt for request (.camera, .microphone, ...); */ false }
}
func remotePermission(request: NativeRemotePermission) async throws -> Bool {
await MainActor.run { /* show prompt for request (.chainSubmit, .remote(domains:), ...); */ false }
}
// Core-owned auth state stream: render `.connected`/`.disconnected` as the
// account badge and `.loginFailed` as a retryable error. This core is a
// signing host — it owns the signer and never pairs — so `.pairing` and
// `.authenticating` are not emitted and `core.cancelLogin()` is inert.
// Activate the session with `core.activateLocalSession(secret:...)`.
func authStateChanged(state: AuthState) {
DispatchQueue.main.async { /* render the state */ }
}
func coreStorageRead(key: Data) throws -> Data? { coreStorage[key] }
func coreStorageWrite(key: Data, value: Data) throws { coreStorage[key] = value }
func coreStorageClear(key: Data) throws { coreStorage.removeValue(forKey: key) }
func chainConnect(genesisHash: Data) throws -> UInt32? {
let id: UInt32 = 1
DispatchQueue.main.async { /* open JSON-RPC connection, forward responses via core.notifyChainResponse */ }
return id
}
func chainSend(connectionId: UInt32, request: String) throws {
/* send JSON-RPC request on the host connection */
}
func chainClose(connectionId: UInt32) throws {
/* close host connection */
}
func confirmUserAction(review: NativeUserConfirmationReview) async throws -> Bool {
// Switch on the review variant (.signPayload, .createTransaction, ...)
// to render the confirmation prompt with its typed fields.
await MainActor.run { /* render review; */ false }
}
func lookupPreimage(key: Data) async throws -> Data? { nil }
func currentTheme() throws -> HostTheme { .dark }
func featureSupported(request: FeatureSupportedRequest) async throws -> Bool { false }
func localStorageRead(key: String) throws -> Data? { storage[key] }
func localStorageWrite(key: String, value: Data) throws { storage[key] = value }
func localStorageClear(key: String) throws { storage.removeValue(forKey: key) }
}
let callbacks = MyCallbacks()
let runtimeConfig = RuntimeConfig(
productId: "my-product.dot",
hostName: "My Host",
hostIcon: "https://host.example/icon.png",
peopleChainGenesisHash: Data(repeating: 0, count: 32),
bulletinChainGenesisHash: Data(repeating: 0, count: 32),
pairingDeeplinkScheme: .polkadotApp
)
let core = try TrUAPIHostCore(callbacks: callbacks, runtimeConfig: runtimeConfig)
try core.activateLocalSession(secret: entropyBytes, liteUsername: nil)
let endpoint = try core.startWsBridge()
// Call these from host/platform observers so native subscriptions see updates
// after their immediate current item.
core.notifyThemeChanged(theme: .dark)
core.notifyPreimageChanged(key: preimageKey, value: preimageBytesOrNil)
core.notifyChainResponse(connectionId: chainConnectionId, json: jsonRpcResponse)
core.notifyChainClosed(connectionId: chainConnectionId)
// Both scripts must be registered before the web view loads the product page,
// and in this order: the bootstrap publishes the bridge endpoint on
// `window.__truapi_localhost`; the container script then locks down the
// page's platform APIs and reads that endpoint at eval time.
let contentController = WKUserContentController()
let bootstrapScript = LocalhostBridgeBootstrap.script(port: endpoint.port, token: endpoint.token)
contentController.addUserScript(WKUserScript(
source: bootstrapScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
contentController.addUserScript(WKUserScript(
source: try ContainerScriptBundle.load(),
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
let configuration = WKWebViewConfiguration()
configuration.userContentController = contentController
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.load(URLRequest(url: URL(string: "https://your-product.example/")!))
// On logout:
core.disconnect()The product page reads window.__truapi_localhost.url (set by the bootstrap script) and passes it to @parity/truapi's createWebSocketProvider(url).
./scripts/rebuild.sh orchestrates everything; the underlying pieces, should you need one in isolation:
- xcframework —
make -C "$TRUAPI_ROOT" xcframeworkbuildstruapi-serverforaarch64-apple-iosandaarch64-apple-ios-simand bundlestarget/truapi_server.xcframeworkinside the truapi checkout; the script copies it intoBinaries/and strips the per-slicemodule.modulemap(module resolution comes from thesystemLibrarytarget; the slice copy collides with other xcframeworks in Xcode's flat include dir). - bindings —
make uniffi(run automatically bymake xcframework) emits the Swift bindings into truapi'starget/uniffi-swift-out/via the truapi workspaceuniffi-bindgen-cli; the script copies them intoSources/TrUAPIHost/truapi_server.swiftandSources/truapi_serverFFI/include/, renaming the emittedtruapi_serverFFI.modulemaptomodule.modulemapso the SwiftPMsystemLibrarytarget picks it up. - container —
npm run buildincontainer/bundlessrc/index.tsintoSources/TrUAPIHost/Resources/truapi-container.js.