diff --git a/.github/workflows/host-packages.yml b/.github/workflows/host-packages.yml index 5d13d1f3..f789dcde 100644 --- a/.github/workflows/host-packages.yml +++ b/.github/workflows/host-packages.yml @@ -4,6 +4,8 @@ on: branches: [main] paths: - 'js/packages/truapi-host-wasm/**' + - 'android/**' + - 'ios/**' - 'rust/crates/truapi-server/**' - 'rust/crates/truapi-platform/**' - 'rust/crates/truapi-codegen/**' @@ -12,6 +14,8 @@ on: pull_request: paths: - 'js/packages/truapi-host-wasm/**' + - 'android/**' + - 'ios/**' - 'rust/crates/truapi-server/**' - 'rust/crates/truapi-platform/**' - 'rust/crates/truapi-codegen/**' @@ -69,3 +73,36 @@ jobs: run: cd js/packages/truapi && npm install --no-fund --no-audit && npm run build - name: Test @parity/truapi-host-wasm run: cd js/packages/truapi-host-wasm && npm install --no-fund --no-audit && npm test + android-assemble: + # Smoke-builds the Android library + verifies the Maven publication + # can be assembled (writes to maven-local). No release happens here; + # distribution is via JitPack, which builds tagged commits per + # jitpack.yml and serves the io.parity:truapi-host-android artifact. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + # No Gradle wrapper in the repo: pin the Gradle version here instead of + # drifting with the runner image. 8.7 is the AGP 8.5.x baseline. + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.7' + # The Kotlin sources import the gitignored uniffi.truapi_server.* + # bindings; generate them from the truapi-server cdylib first. + - name: Generate UniFFI bindings + run: make uniffi + - name: Assemble truapi-host-android (release) + run: gradle :truapi-host:assembleRelease --no-daemon --stacktrace + - name: Build Maven publication into local repo + run: gradle :truapi-host:publishReleasePublicationToMavenLocal --no-daemon --stacktrace + - name: Sanity-check published artifacts + run: | + set -eux + base="$HOME/.m2/repository/io/parity/truapi-host-android/0.1.0" + test -s "$base/truapi-host-android-0.1.0.aar" + test -s "$base/truapi-host-android-0.1.0.pom" + test -s "$base/truapi-host-android-0.1.0-sources.jar" diff --git a/Cargo.lock b/Cargo.lock index 40f8c40e..3ae5782e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3356,13 +3356,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" dependencies = [ "anyhow", + "camino", "cargo_metadata", + "clap", "uniffi_bindgen", "uniffi_core", "uniffi_macros", "uniffi_pipeline", ] +[[package]] +name = "uniffi-bindgen-cli" +version = "0.1.0" +dependencies = [ + "uniffi", +] + [[package]] name = "uniffi_bindgen" version = "0.29.5" diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..98778d9e --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md new file mode 100644 index 00000000..180cc755 --- /dev/null +++ b/android/truapi-host/README.md @@ -0,0 +1,269 @@ +# TrUAPI Android host adapter + +*Kotlin wrapper around the TrUAPI Rust core (UniFFI). Wire decoding, request routing, and subscription lifecycle stay in the Rust core; products connect through the localhost WebSocket bridge.* + +Distributed as a Maven artifact built on demand from git tags by [JitPack](https://jitpack.io/), no Maven Central account required on either side. + +## Consume + +Add the JitPack Maven repository and the artifact to your app's Gradle build: + +```kotlin +// settings.gradle.kts +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} +``` + +```kotlin +// app/build.gradle.kts +dependencies { + implementation("com.github.paritytech.truapi:truapi-host:0.1.0") +} +``` + +JitPack fetches the tag `0.1.0` from `paritytech/truapi`, runs `make android-publish-local` against it (driven by `jitpack.yml` at the repo root, including UniFFI binding generation), and serves the resulting AAR + POM + sources jar. First fetch takes ~1 minute while JitPack builds; subsequent consumers hit the cache. + +The artifact bundles the Kotlin host adapter (`io.parity.truapi.*`) and the generated UniFFI bindings (`uniffi.truapi_server.*`). It does **not** bundle the native `libtruapi_server.so` cdylib, integrators build that per Android ABI and drop it into their app's `src/main/jniLibs//` (see "Linking the cdylib" below). + +### Compatibility + +- **minSdk**: 29 (Android 10). Aligns with the polkadot-app-android-v2 floor. +- **AGP**: built with 8.5.2; AGP 8.5+ consumers are fine. AAR is forward-compatible with newer AGPs. +- **Kotlin**: built with 1.9.24. Newer Kotlin compilers (2.x) read 1.9 metadata fine. +- **Transitive dependency**: the AAR pulls `net.java.dev.jna:jna:5.14.0` (UniFFI's runtime). Consumers that don't already use JNA will see ~1.5MB added to their app. + +## Public surface + +The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](src/main/kotlin/io/parity/truapi/TrUAPIHost.kt): + +- `HostBridge` - callback bundle the embedding app implements. Splits device permissions, remote permissions, navigation, push, feature support, and scoped storage. +- `HostStorage` - read/write/clear interface the host backs with its own persistence. +- `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core and exposes the localhost WebSocket bridge, core-owned disconnect, and native change notifications for session storage, theme, and preimage updates. + +## Architecture + +```text +product app in WebView + Uint8Array frames via @parity/truapi createWebSocketProvider + | + v ws://127.0.0.1:/?t= +TrUAPIHostCore.startWsBridge() + → libtruapi_server.so (tokio WS server) + → Rust dispatcher +``` + +The product running in the `WebView` 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`, session storage, chain JSON-RPC, confirmations, preimage, theme, `featureSupported`, `storage`) reach the embedder through `HostBridge`. + +## Permissions split + +The core's `Permissions` platform trait has two methods, and so does the bridge: + +- `devicePermission(request)` - OS-scoped grants (camera, mic, location, push). `request` is a SCALE-encoded `v01::HostDevicePermissionRequest`. +- `remotePermission(request)` - per-product capability bundles. `request` is a SCALE-encoded `v01::RemotePermissionRequest`. + +Both return a `Boolean` granted flag. SCALE decoding for the UI prompt is done by the `@parity/truapi` JS client (or any consumer that links the protocol crate's types directly). + +## Example + +> **Threading:** the Rust core invokes every `HostBridge` callback on a +> background thread it owns, never the UI thread. Marshal any UI work +> (navigation, prompts, notifications, touching the `WebView`) onto the main +> thread with `Handler(Looper.getMainLooper())` or a `Dispatchers.Main` +> `CoroutineScope`. UI-decision callbacks (`navigateTo`, `devicePermission`, +> `remotePermission`, the `confirm*` family, `submitPreimage`) each run on +> their own blocking-pool thread, so it is safe to block the calling thread +> (e.g. with a `CountDownLatch`) until the main-thread prompt resolves; other +> TrUAPI traffic keeps flowing while you wait. The remaining callbacks (auth +> state, storage, session, chain, feature, theme, preimage lookups) run +> inline on the dispatcher thread and must return promptly without blocking. + +```kt +import android.os.Handler +import android.os.Looper +import android.webkit.WebView +import io.parity.truapi.HostBridge +import io.parity.truapi.HostStorage +import io.parity.truapi.PairingDeeplinkScheme +import io.parity.truapi.RuntimeConfig +import io.parity.truapi.TrUAPIHostCore +import uniffi.truapi_server.AuthState +import uniffi.truapi_server.HostTheme +import java.util.concurrent.CountDownLatch + +class MyStorage : HostStorage { + private val map = mutableMapOf() + override fun read(key: String) = map[key] + override fun write(key: String, value: ByteArray) { map[key] = value } + override fun clear(key: String) { map.remove(key) } +} + +class MyBridge(private val webView: WebView) : HostBridge { + private val main = Handler(Looper.getMainLooper()) + + override val storage = MyStorage() + + override fun navigateTo(url: String) { + main.post { /* startActivity(Intent(ACTION_VIEW, Uri.parse(url))) */ } + } + + override fun pushNotification(payload: ByteArray): UInt { + val id = 1u + main.post { /* show notification */ } + return id + } + + override fun cancelNotification(id: UInt) { + main.post { /* cancel notification */ } + } + + override fun devicePermission(request: ByteArray): Boolean { + // Called on a blocking-pool thread; prompt on the main thread and + // wait. Blocking here does not stall other TrUAPI traffic. + val latch = CountDownLatch(1) + var granted = false + main.post { /* show prompt, set granted, then */ latch.countDown() } + latch.await() + return granted + } + + override fun remotePermission(request: ByteArray): Boolean = false + override fun featureSupported(request: ByteArray): Boolean = false + + // Core-owned auth state stream: render AuthState.Pairing as the pairing + // QR sheet, connected/disconnected as the account badge, and login-failed + // as a retryable error. When the user closes the pairing sheet, report it + // with `core.cancelLogin()`. + override fun authStateChanged(state: AuthState) { + main.post { /* render the state */ } + } + + override fun chainConnect(genesisHash: ByteArray): UInt? { + val id = 1u + main.post { /* open JSON-RPC connection, forward responses via core.notifyChainResponse */ } + return id + } + + override fun chainSend(connectionId: UInt, request: String) { + /* send JSON-RPC request on the host connection */ + } + + override fun chainClose(connectionId: UInt) { + /* close host connection */ + } +} + +val webView: WebView = existingWebView +val runtimeConfig = RuntimeConfig( + productLabel = "my-product", + productId = "my-product.dot", + siteId = "host.example", + hostName = "My Host", + hostIcon = "https://host.example/icon.png", + peopleChainGenesisHash = ByteArray(32), + pairingDeeplinkScheme = PairingDeeplinkScheme.POLKADOT_APP, +) +val core = TrUAPIHostCore(MyBridge(webView), runtimeConfig) +val endpoint = core.startWsBridge() +val wsUrl = "ws://127.0.0.1:${endpoint.port.toInt()}/?t=${endpoint.token}" + +// Call these from host/platform observers so native subscriptions see updates +// after their immediate current item. +core.notifySessionStoreChanged() +core.notifyThemeChanged(HostTheme.DARK) +core.notifyPreimageChanged(preimageKey, preimageBytesOrNull) +core.notifyChainResponse(chainConnectionId, jsonRpcResponse) +core.notifyChainClosed(chainConnectionId) + +// Inject `wsUrl` into the product page; product JS calls +// `@parity/truapi`'s `createWebSocketProvider(wsUrl)` to open the wire. +webView.loadUrl("https://your-product.example/?truapi=${java.net.URLEncoder.encode(wsUrl, "UTF-8")}") + +// On logout: +core.disconnect() +``` + +## Linking the cdylib + +The native runtime ships separately. JNA looks for `libtruapi_server.so` in the standard `jniLibs` paths; bundle the per-ABI builds under: + +``` +src/main/jniLibs/arm64-v8a/libtruapi_server.so +src/main/jniLibs/armeabi-v7a/libtruapi_server.so +src/main/jniLibs/x86_64/libtruapi_server.so +``` + +Cross-build the cdylib for each Android ABI from the truapi monorepo. Two options, pick whichever fits the host app's existing toolchain: + +**Option A: `mozilla-rust-android-gradle` plugin.** Recommended if the host app already uses it (polkadot-app-android-v2 does, for `bandersnatch-crypto`). Vendor `paritytech/truapi` as a git submodule, add a small Gradle module that points the plugin at `rust/crates/truapi-server`: + +```kotlin +// app/build.gradle.kts (or a dedicated :truapi-cdylib module) +plugins { + alias(libs.plugins.mozilla.rust.android) +} + +cargo { + module = "/truapi/rust/crates/truapi-server" + libname = "truapi_server" + targets = listOf("arm64", "arm", "x86_64") + profile = "release" + features { defaultAnd(arrayOf("ws-bridge")) } +} + +tasks.matching { it.name.matches("merge.*JniLibFolders".toRegex()) }.configureEach { + inputs.dir(layout.buildDirectory.dir("rustJniLibs/android")) + dependsOn("cargoBuild") +} +``` + +**Option B: `cargo-ndk` from the command line.** Standalone, no Gradle plugin required: + +```bash +cargo install cargo-ndk +cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \ + -o app/src/main/jniLibs \ + build --release -p truapi-server --features ws-bridge +``` + +Both options require the Android NDK installed and the matching Rust targets (`rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android`). + +Pre-built per-ABI `.so` files bundled inside the AAR are tracked as a follow-up so consumers eventually don't need a Rust toolchain at all. + +## Maintainers: cutting a release + +JitPack builds on demand from any git tag in `paritytech/truapi`, so a release is just: + +1. Bump `publicationVersion` in `android/truapi-host/build.gradle.kts`. +2. Commit. Open a PR. Merge. +3. Tag the merge commit with the version: `git tag truapi-host-android@0.1.0 && git push origin truapi-host-android@0.1.0`. + +That's the entire release flow, the iOS Swift Package follows the same pattern. The first consumer to pull the tag will trigger JitPack to build the artifact; subsequent fetches hit the cache. + +For local development, publish into the dev `~/.m2`: + +```bash +gradle :truapi-host:publishReleasePublicationToMavenLocal +# or +make android-publish-local +``` + +The artifact lands under `~/.m2/repository/io/parity/truapi-host-android//`. Consumers pointing at `mavenLocal()` can resolve it via `io.parity:truapi-host-android:`. These local coordinates differ from the JitPack consumer coordinate (`com.github.paritytech.truapi:truapi-host:`): JitPack derives the group and artifactId from the repo and Gradle subproject, overriding the `io.parity:truapi-host-android` coordinates set in `build.gradle.kts`. + +## Regenerating the UniFFI bindings + +The ignored Kotlin bindings under `src/main/kotlin/generated/uniffi/` are produced from the workspace `uniffi-bindgen-cli`. Regenerate them before building or publishing the Android host package: + +```bash +cargo build -p truapi-server --release --features ws-bridge +cargo run -p uniffi-bindgen-cli -- generate \ + --library target/release/libtruapi_server.so \ + --language kotlin \ + --out-dir android/truapi-host/src/main/kotlin/generated +``` + +Or run `make uniffi` from the repo root. diff --git a/android/truapi-host/build.gradle.kts b/android/truapi-host/build.gradle.kts new file mode 100644 index 00000000..c79d8a3c --- /dev/null +++ b/android/truapi-host/build.gradle.kts @@ -0,0 +1,118 @@ +// TrUAPI Android host adapter. +// +// Publishes `io.parity:truapi-host-android` to Maven. Products running in a +// `WebView` connect to the Rust core via its localhost WebSocket bridge +// (`TrUAPIHostCore.startWsBridge`); the Rust core (compiled to +// `libtruapi_server.so`) handles wire decoding, routing, subscription +// lifecycle, and host capability dispatch. + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("maven-publish") +} + +android { + namespace = "io.parity.truapi" + compileSdk = 34 + + defaultConfig { + // minSdk 29 matches the polkadot-app-android-v2 floor; raise here + // first and bump consumers' floors if we ever depend on a newer API. + minSdk = 29 + consumerProguardFiles("consumer-rules.pro") + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + manifest.srcFile("src/main/AndroidManifest.xml") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +dependencies { + // UniFFI Kotlin bindings use JNA for FFI. + api("net.java.dev.jna:jna:5.14.0@aar") +} + +// Coordinates for the local Maven publication (`publishToMavenLocal`). +// Distribution is via JitPack: a git tag drives `jitpack.yml`, and JitPack +// derives the consumer coordinates from the repo + subproject as +// `com.github.paritytech.truapi:truapi-host:`, overriding the group and +// artifactId below. These fields only matter for local testing. +val publicationGroup = "io.parity" +val publicationArtifact = "truapi-host-android" +val publicationVersion = "0.1.0" + +group = publicationGroup +version = publicationVersion + +publishing { + publications { + register("release") { + groupId = publicationGroup + artifactId = publicationArtifact + version = publicationVersion + + afterEvaluate { + from(components["release"]) + } + + pom { + name.set("TrUAPI Android host adapter") + description.set( + "Kotlin wrapper around the TrUAPI Rust core (UniFFI). " + + "Hosts integrating a `WebView`-based product link the " + + "`libtruapi_server` cdylib and route product traffic " + + "through the localhost WebSocket bridge." + ) + url.set("https://github.com/paritytech/truapi") + licenses { + license { + name.set("MIT") + url.set("https://github.com/paritytech/truapi/blob/main/LICENSE") + } + } + scm { + connection.set("scm:git:https://github.com/paritytech/truapi.git") + developerConnection.set("scm:git:ssh://git@github.com/paritytech/truapi.git") + url.set("https://github.com/paritytech/truapi") + } + developers { + developer { + name.set("Parity Technologies") + email.set("admin@parity.io") + organization.set("Parity Technologies") + organizationUrl.set("https://parity.io") + } + } + } + } + } + + repositories { + // Maven Local for `gradle publishToMavenLocal` during development + // and for JitPack's build environment (see `jitpack.yml`). + // Consumers fetch the published artifact via JitPack at + // `com.github.paritytech.truapi:truapi-host:` after the + // repo is tagged. + mavenLocal() + } +} diff --git a/android/truapi-host/consumer-rules.pro b/android/truapi-host/consumer-rules.pro new file mode 100644 index 00000000..759ad9b2 --- /dev/null +++ b/android/truapi-host/consumer-rules.pro @@ -0,0 +1,11 @@ +# ProGuard / R8 rules applied to consumers of `io.parity:truapi-host-android`. +# +# JNA reflects into our generated UniFFI types at runtime, so the bindings +# package and the public Kotlin surface must survive shrinking. + +-keep class uniffi.truapi_server.** { *; } +-keep class io.parity.truapi.** { *; } + +# JNA itself. +-keep class com.sun.jna.** { *; } +-keepclassmembers class * extends com.sun.jna.** { *; } diff --git a/android/truapi-host/src/main/AndroidManifest.xml b/android/truapi-host/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/android/truapi-host/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt new file mode 100644 index 00000000..5c57aae5 --- /dev/null +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -0,0 +1,462 @@ +// TrUAPIHost - Android host adapter. +// +// The Rust core (compiled to `libtruapi_server.so` and surfaced via UniFFI in +// `src/main/kotlin/generated/uniffi/truapi_server/truapi_server.kt`) owns the +// wire protocol, request routing, subscription lifecycle, and platform trait +// dispatch. +// +// This file exposes: +// +// * `HostBridge` - the Kotlin-friendly callback interface the embedding app +// implements. It splits device and remote permissions, mirroring the +// `Permissions` platform trait in the Rust core. +// * `TrUAPIHostCore` - owning wrapper around the UniFFI-generated +// `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the +// core and exposes session + WS-bridge controls. +// +// Products running inside a `WebView` connect to the Rust core via the +// localhost WebSocket bridge. Start it with `core.startWsBridge()` and load +// the product page with the resulting `ws://127.0.0.1:/?t=` URL +// passed through to the product's `@parity/truapi` `createWebSocketProvider` +// call. + +package io.parity.truapi + +import uniffi.truapi_server.AuthState +import uniffi.truapi_server.HostCallbacks +import uniffi.truapi_server.HostNavigateRejection +import uniffi.truapi_server.HostRejection +import uniffi.truapi_server.HostStorageException +import uniffi.truapi_server.HostTheme +import uniffi.truapi_server.NativeTrUApiCore +import uniffi.truapi_server.NativePairingDeeplinkScheme as UniFfiNativePairingDeeplinkScheme +import uniffi.truapi_server.NativeRuntimeConfig as UniFfiNativeRuntimeConfig +import uniffi.truapi_server.NativeRuntimeConfigException +import uniffi.truapi_server.WsBridgeEndpoint +import uniffi.truapi_server.WsBridgeStartException + +/** Package metadata. */ +object TrUAPIHost { + const val VERSION = "0.1.0" +} + +/** Deeplink scheme used when the Rust core builds SSO pairing payloads. */ +enum class PairingDeeplinkScheme { + POLKADOT_APP, + POLKADOT_APP_DEV; + + internal fun toNative(): UniFfiNativePairingDeeplinkScheme = + when (this) { + POLKADOT_APP -> UniFfiNativePairingDeeplinkScheme.POLKADOT_APP + POLKADOT_APP_DEV -> UniFfiNativePairingDeeplinkScheme.POLKADOT_APP_DEV + } +} + +/** + * Static product and pairing config supplied before the Rust core handles + * product calls. One core instance represents one product identity. + * + * [hostName], [hostIcon], [hostVersion], [platformType], and + * [platformVersion] describe the host to the wallet during SSO pairing. + * [peopleChainGenesisHash] must be exactly 32 bytes. + */ +data class RuntimeConfig( + val productLabel: String, + val productId: String, + val siteId: String, + val hostName: String, + val hostIcon: String? = null, + val hostVersion: String? = null, + val platformType: String? = null, + val platformVersion: String? = null, + val peopleChainGenesisHash: ByteArray, + val pairingDeeplinkScheme: PairingDeeplinkScheme = PairingDeeplinkScheme.POLKADOT_APP, +) { + internal fun toNative(): UniFfiNativeRuntimeConfig = + UniFfiNativeRuntimeConfig( + productLabel = productLabel, + productId = productId, + siteId = siteId, + hostName = hostName, + hostIcon = hostIcon, + hostVersion = hostVersion, + platformType = platformType, + platformVersion = platformVersion, + peopleChainGenesisHash = peopleChainGenesisHash, + pairingDeeplinkScheme = pairingDeeplinkScheme.toNative(), + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RuntimeConfig) return false + return productLabel == other.productLabel && + productId == other.productId && + siteId == other.siteId && + hostName == other.hostName && + hostIcon == other.hostIcon && + hostVersion == other.hostVersion && + platformType == other.platformType && + platformVersion == other.platformVersion && + peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && + pairingDeeplinkScheme == other.pairingDeeplinkScheme + } + + override fun hashCode(): Int { + var result = productLabel.hashCode() + result = 31 * result + productId.hashCode() + result = 31 * result + siteId.hashCode() + result = 31 * result + hostName.hashCode() + result = 31 * result + (hostIcon?.hashCode() ?: 0) + result = 31 * result + (hostVersion?.hashCode() ?: 0) + result = 31 * result + (platformType?.hashCode() ?: 0) + result = 31 * result + (platformVersion?.hashCode() ?: 0) + result = 31 * result + peopleChainGenesisHash.contentHashCode() + result = 31 * result + pairingDeeplinkScheme.hashCode() + return result + } +} + +/** + * Storage backend the host provides to the Rust core. Throws + * [HostStorageException] to signal quota exhaustion or unknown failure; the + * core maps both onto the v0.1 `HostLocalStorageReadError` wire shape. + */ +interface HostStorage { + @Throws(HostStorageException::class) + fun read(key: String): ByteArray? + + @Throws(HostStorageException::class) + fun write(key: String, value: ByteArray) + + @Throws(HostStorageException::class) + fun clear(key: String) +} + +/** + * Host-side callback bundle that the Rust core invokes for capabilities the + * native shell owns. The interface mirrors the underlying UniFFI surface + * but keeps the permission split explicit: + * + * * [devicePermission] handles camera / mic / push prompts and similar + * OS-scoped grants. `request` is a SCALE-encoded + * `v01::HostDevicePermissionRequest`. + * * [remotePermission] handles per-product capability bundles requested + * by the application running inside the WebView. `request` is a + * SCALE-encoded `v01::RemotePermissionRequest`. + * + * Embedders typically wire the SCALE payloads through the generated + * `@parity/truapi` client running on the JS side for UI rendering, then + * report the user's decision as a `Boolean`. + * + * Threading: the Rust core invokes every callback on a background thread it + * owns, never the UI (main) thread. UI-decision callbacks ([navigateTo], + * [devicePermission], [remotePermission], the `confirm*` family, and + * [submitPreimage]) each run on their own thread from a blocking pool, so an + * implementation may safely block its calling thread (e.g. with a + * `CountDownLatch`) until the user decides; other TrUAPI traffic keeps + * flowing. The remaining callbacks (auth state, storage, session, chain, + * feature, theme, preimage lookups) run inline on the dispatcher thread and + * must return promptly without blocking. Any UI work MUST still be marshalled + * onto the main thread, e.g. with `Handler(Looper.getMainLooper()).post { ... }` + * or a `CoroutineScope` bound to `Dispatchers.Main`. Touching views or the + * `WebView` directly from a callback throws `CalledFromWrongThreadException`. + */ +interface HostBridge { + /** Lifecycle logger. Marker is a stable slug, detail is free-form. */ + fun onCoreLog(marker: String, detail: String) {} + + /** + * Open a URL in the system browser. Invoked on a blocking-pool thread; + * marshal the UI launch (e.g. `startActivity`) to the main thread. May + * block the calling thread if the user has to approve the navigation. + */ + @Throws(HostNavigateRejection::class) + fun navigateTo(url: String) + + /** + * Deliver a push notification (SCALE-encoded `HostPushNotificationRequest`) + * and return the host-assigned notification id. Invoked on the dispatcher + * thread; marshal any UI work to the main thread and return promptly. + */ + @Throws(HostRejection::class) + fun pushNotification(payload: ByteArray): UInt = 0u + + /** Cancel a previously scheduled notification id. */ + @Throws(HostRejection::class) + fun cancelNotification(id: UInt) {} + + /** + * Prompt for a device-level permission. Returns whether it was granted. + * Invoked on a blocking-pool thread; present the prompt on the main + * thread and block the calling thread until the user decides. Blocking + * here does not stall other TrUAPI traffic. + */ + @Throws(HostRejection::class) + fun devicePermission(request: ByteArray): Boolean + + /** + * Prompt for a remote (product-scoped) permission bundle. Invoked on a + * blocking-pool thread; present the prompt on the main thread and block + * the calling thread until the user decides. Blocking here does not stall + * other TrUAPI traffic. + */ + @Throws(HostRejection::class) + fun remotePermission(request: ByteArray): Boolean + + /** + * Observe an auth state change. The core emits states only when they + * actually change, in transition order: render [AuthState.Pairing] + * as the pairing QR UI, connected/disconnected as the account badge, and + * login-failed as a retryable error. Report a user dismissal of the + * pairing UI through [TrUAPIHostCore.cancelLogin]. Invoked on the + * dispatcher thread; marshal the state to the main thread and return + * promptly. + */ + fun authStateChanged(state: AuthState) {} + + /** Read the opaque core-owned SSO session blob from host-global storage. */ + @Throws(HostRejection::class) + fun readSession(): ByteArray? = null + + /** Persist the opaque core-owned SSO session blob in host-global storage. */ + @Throws(HostRejection::class) + fun writeSession(value: ByteArray) {} + + /** Clear the persisted core-owned SSO session blob. */ + @Throws(HostRejection::class) + fun clearSession() {} + + /** Open a JSON-RPC chain connection and return a host-assigned id, or null if unsupported. */ + @Throws(HostRejection::class) + fun chainConnect(genesisHash: ByteArray): UInt? = null + + /** Send one JSON-RPC request on a native chain connection. */ + @Throws(HostRejection::class) + fun chainSend(connectionId: UInt, request: String) {} + + /** Close a native chain connection. */ + @Throws(HostRejection::class) + fun chainClose(connectionId: UInt) {} + + /** Confirm a sign-payload request before the core asks the SSO peer. */ + @Throws(HostRejection::class) + fun confirmSignPayload(review: ByteArray): Boolean = false + + /** Confirm a sign-raw request before the core asks the SSO peer. */ + @Throws(HostRejection::class) + fun confirmSignRaw(review: ByteArray): Boolean = false + + /** Confirm a create-transaction request before the core asks the SSO peer. */ + @Throws(HostRejection::class) + fun confirmCreateTransaction(review: ByteArray): Boolean = false + + /** Confirm a cross-domain account-alias request before the core asks the SSO peer. */ + @Throws(HostRejection::class) + fun confirmAccountAlias(review: ByteArray): Boolean = false + + /** Confirm a resource-allocation request before the core asks the SSO peer. */ + @Throws(HostRejection::class) + fun confirmResourceAllocation(review: ByteArray): Boolean = false + + /** Confirm preimage submission before the host stores it. */ + @Throws(HostRejection::class) + fun confirmPreimageSubmit(size: ULong) {} + + /** Submit a preimage through the host backend and return its key. */ + @Throws(HostRejection::class) + fun submitPreimage(value: ByteArray): ByteArray = value + + /** Return the current preimage value for [key], or null for a miss. */ + @Throws(HostRejection::class) + fun lookupPreimage(key: ByteArray): ByteArray? = null + + /** Return the current host theme. */ + @Throws(HostRejection::class) + fun currentTheme(): HostTheme = HostTheme.DARK + + /** + * Answer a feature-support query. Invoked on the dispatcher thread; must + * return promptly. + */ + @Throws(HostRejection::class) + fun featureSupported(request: ByteArray): Boolean + + /** Scoped key-value storage for the Rust core. */ + val storage: HostStorage +} + +/** + * Adapter from the public [HostBridge] surface to the generated UniFFI + * [HostCallbacks] interface. Keeps the public API stable even if uniffi-bindgen + * renames generated symbols. + */ +private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallbacks { + override fun onCoreLog(marker: String, detail: String) = + bridge.onCoreLog(marker, detail) + + override fun navigateTo(url: String) = + bridge.navigateTo(url) + + override fun pushNotification(payload: ByteArray): UInt = + bridge.pushNotification(payload) + + override fun cancelNotification(id: UInt) = + bridge.cancelNotification(id) + + override fun devicePermission(request: ByteArray): Boolean = + bridge.devicePermission(request) + + override fun remotePermission(request: ByteArray): Boolean = + bridge.remotePermission(request) + + override fun authStateChanged(state: AuthState) = + bridge.authStateChanged(state) + + override fun readSession(): ByteArray? = + bridge.readSession() + + override fun writeSession(value: ByteArray) = + bridge.writeSession(value) + + override fun clearSession() = + bridge.clearSession() + + override fun chainConnect(genesisHash: ByteArray): UInt? = + bridge.chainConnect(genesisHash) + + override fun chainSend(connectionId: UInt, request: String) = + bridge.chainSend(connectionId, request) + + override fun chainClose(connectionId: UInt) = + bridge.chainClose(connectionId) + + override fun confirmSignPayload(review: ByteArray): Boolean = + bridge.confirmSignPayload(review) + + override fun confirmSignRaw(review: ByteArray): Boolean = + bridge.confirmSignRaw(review) + + override fun confirmCreateTransaction(review: ByteArray): Boolean = + bridge.confirmCreateTransaction(review) + + override fun confirmAccountAlias(review: ByteArray): Boolean = + bridge.confirmAccountAlias(review) + + override fun confirmResourceAllocation(review: ByteArray): Boolean = + bridge.confirmResourceAllocation(review) + + override fun confirmPreimageSubmit(size: ULong) = + bridge.confirmPreimageSubmit(size) + + override fun submitPreimage(value: ByteArray): ByteArray = + bridge.submitPreimage(value) + + override fun lookupPreimage(key: ByteArray): ByteArray? = + bridge.lookupPreimage(key) + + override fun currentTheme(): HostTheme = + bridge.currentTheme() + + override fun featureSupported(request: ByteArray): Boolean = + bridge.featureSupported(request) + + override fun localStorageRead(key: String): ByteArray? = + bridge.storage.read(key) + + override fun localStorageWrite(key: String, value: ByteArray) = + bridge.storage.write(key, value) + + override fun localStorageClear(key: String) = + bridge.storage.clear(key) +} + +/** + * Owning wrapper around the Rust-backed [NativeTrUApiCore]. Holds the bridge + * alive for the lifetime of the core and exposes core lifecycle + WS-bridge + * controls. + * + * Hosts integrating with a `WebView`-based product call [startWsBridge] and + * pass the resulting `ws://127.0.0.1:/?t=` URL to the product + * (typically via a query string or page-bootstrap hook). The product wires + * that URL into `@parity/truapi`'s `createWebSocketProvider`. + */ +class TrUAPIHostCore private constructor( + bridge: HostBridge, + runtimeConfig: UniFfiNativeRuntimeConfig, +) : AutoCloseable { + @Throws(NativeRuntimeConfigException::class) + constructor(bridge: HostBridge, runtimeConfig: RuntimeConfig) : this( + bridge, + runtimeConfig.toNative(), + ) + + // Co-owns the adapter alongside the generated FfiConverter handle map, + // which is what actually keeps the callback object alive for the core. + private val callbackRetainer: HostCallbacks = HostCallbackAdapter(bridge) + private val inner: NativeTrUApiCore = + NativeTrUApiCore.withRuntimeConfig(callbackRetainer, runtimeConfig) + + /** + * Start the localhost WebSocket bridge (requires the `ws-bridge` feature + * in the cdylib). The returned [WsBridgeEndpoint] carries the port and + * session token; build a `ws://127.0.0.1:/?t=` URL and pass + * it to the product's `createWebSocketProvider` call. + */ + @Throws(WsBridgeStartException::class) + fun startWsBridge(bindPort: UShort = 0u): WsBridgeEndpoint = + inner.startWsBridge(bindPort) + + /** Stop the localhost WebSocket bridge (if running). */ + fun stopWsBridge() { + inner.stopWsBridge() + } + + /** + * Core-owned logout/disconnect path. Best-effort notifies the SSO peer, + * clears in-memory session state, clears the persisted session via + * [HostBridge.clearSession], and broadcasts `Disconnected` to active + * account-status subscribers. + */ + fun disconnect() { + inner.disconnect() + } + + /** Notify the core that host-global session storage changed externally. */ + fun notifySessionStoreChanged() { + inner.notifySessionStoreChanged() + } + + /** + * Cancel any in-flight login pairing (e.g. the user dismissed the pairing + * UI). The bridge receives a disconnected auth state immediately and the + * pending login resolves as rejected. A no-op when no login is in + * progress. + */ + fun cancelLogin() { + inner.cancelLogin() + } + + /** Push a host theme update to active TrUAPI theme subscriptions. */ + fun notifyThemeChanged(theme: HostTheme) { + inner.notifyThemeChanged(theme) + } + + /** Push a preimage lookup update to active subscriptions for [key]. */ + fun notifyPreimageChanged(key: ByteArray, value: ByteArray?) { + inner.notifyPreimageChanged(key, value) + } + + /** Push a JSON-RPC response from a native chain connection into the core. */ + fun notifyChainResponse(connectionId: UInt, json: String) { + inner.notifyChainResponse(connectionId, json) + } + + /** Notify the core that a native chain connection closed externally. */ + fun notifyChainClosed(connectionId: UInt) { + inner.notifyChainClosed(connectionId) + } + + override fun close() { + inner.close() + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..ab7c47e3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,8 @@ +// Empty root project. All build logic lives in the `:truapi-host` module +// under `android/`. This file exists so `./gradlew` finds a build script at +// the workspace root. + +plugins { + id("com.android.library") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..7ce28a0f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +android.useAndroidX=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/ios/truapi-host/.gitignore b/ios/truapi-host/.gitignore new file mode 100644 index 00000000..11cc1dd7 --- /dev/null +++ b/ios/truapi-host/.gitignore @@ -0,0 +1,4 @@ +.build/ +DerivedData/ +*.xcodeproj/xcuserdata/ +Package.resolved diff --git a/ios/truapi-host/Package.swift b/ios/truapi-host/Package.swift new file mode 100644 index 00000000..d96ddd88 --- /dev/null +++ b/ios/truapi-host/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 5.9 +// +// TrUAPI iOS host package. +// +// The `truapi_serverFFI` target wraps the UniFFI-generated C header + module +// map so the generated Swift bindings can `import truapi_serverFFI`. The +// `TrUAPIHost` target contains both the generated Swift bindings and the +// thin host shell defined in `TrUAPIHost.swift`. +// +// Consumers must link a prebuilt `libtruapi_server` static or dynamic +// library when integrating into their app target. This package does not +// vendor the binary itself; see README.md for build instructions. + +import PackageDescription + +let package = Package( + name: "TrUAPIHost", + platforms: [.iOS(.v16), .macOS(.v13)], + products: [ + .library(name: "TrUAPIHost", targets: ["TrUAPIHost"]), + ], + targets: [ + .systemLibrary( + name: "truapi_serverFFI", + path: "Sources/truapi_serverFFI", + pkgConfig: nil, + providers: [] + ), + .target( + name: "TrUAPIHost", + dependencies: ["truapi_serverFFI"], + path: "Sources/TrUAPIHost" + ), + ] +) diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md new file mode 100644 index 00000000..4118cc25 --- /dev/null +++ b/ios/truapi-host/README.md @@ -0,0 +1,193 @@ +# TrUAPI iOS host adapter + +*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.* + +## What this package is for + +The public surface lives in [`Sources/TrUAPIHost/TrUAPIHost.swift`](Sources/TrUAPIHost/TrUAPIHost.swift): + +- `HostBridge` - callback bundle the embedding app implements. Split into device permissions, remote permissions, navigation, push, feature support, and scoped storage. +- `HostStorageBackend` - simple read/write/clear protocol the host backs with its own persistence. +- `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core and exposes the localhost WebSocket bridge, core-owned disconnect, and native change notifications for session storage, theme, and preimage updates. +- `LocalhostBridgeBootstrap` - helper that produces a JS snippet publishing the WS bridge endpoint to the product page so it can dial back in. + +The generated UniFFI bindings live alongside the shell in `Sources/TrUAPIHost/truapi_server.swift` and the C header / module map in `Sources/truapi_serverFFI/include/`. They are ignored build outputs; regenerate them before building or publishing the Swift package. + +## Architecture + +```text +product app in WKWebView + Uint8Array frames via @parity/truapi createWebSocketProvider + | + v ws://127.0.0.1:/?t= +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`, session storage, chain JSON-RPC, confirmations, preimage, theme, `featureSupported`, `storage`) reach the embedder through `HostBridge`. + +## Permissions split + +The core's `Permissions` platform trait has two methods, and so does the bridge: + +- `devicePermission(request:)` - OS-scoped grants (camera, mic, location, push). `request` is a SCALE-encoded `v01::HostDevicePermissionRequest`. +- `remotePermission(request:)` - per-product capability bundles. `request` is a SCALE-encoded `v01::RemotePermissionRequest`. + +Both return a `Bool` granted flag. SCALE decoding for the UI prompt is done by the `@parity/truapi` JS client (or any consumer that links the protocol crate's types directly). + +## Example + +> **Threading:** the Rust core invokes every `HostBridge` callback on a +> background thread it owns, never the main thread. Hop to the main thread +> (`DispatchQueue.main` / `MainActor`) before touching UIKit, WebKit, or the +> `WKWebView`. UI-decision callbacks (`navigateTo`, `devicePermission`, +> `remotePermission`, the `confirm*` family, `submitPreimage`) each run on +> their own blocking-pool thread, so it is safe to use +> `DispatchQueue.main.sync` (or a semaphore) to present the prompt on the +> main thread and block the calling thread until the user decides; other +> TrUAPI traffic keeps flowing while you wait. The remaining callbacks (auth +> state, storage, session, chain, feature, theme, preimage lookups) run +> inline on the dispatcher thread and must return promptly without blocking. + +```swift +import Foundation +import WebKit +import TrUAPIHost + +final class MyStorage: HostStorageBackend, @unchecked Sendable { + private var map: [String: Data] = [:] + func read(key: String) throws -> Data? { map[key] } + func write(key: String, value: Data) throws { map[key] = value } + func clear(key: String) throws { map.removeValue(forKey: key) } +} + +final class MyBridge: HostBridge, @unchecked Sendable { + let storage: HostStorageBackend = MyStorage() + + // Callbacks arrive on background threads, never the main thread. + // Hop to the main thread before touching UIKit/WebKit. + func navigateTo(url: String) throws { + DispatchQueue.main.async { /* UIApplication.shared.open(...) */ } + } + + func pushNotification(payload: Data) throws -> UInt32 { + let id: UInt32 = 1 + DispatchQueue.main.async { /* schedule notification */ } + return id + } + + func cancelNotification(id: UInt32) throws { + DispatchQueue.main.async { /* cancel notification */ } + } + + func devicePermission(request: Data) throws -> Bool { + // Called on a blocking-pool thread; present synchronously on the main + // thread and return the decision. Blocking here does not stall other + // TrUAPI traffic. + DispatchQueue.main.sync { /* show prompt; */ false } + } + + func remotePermission(request: Data) throws -> Bool { + DispatchQueue.main.sync { /* show prompt; */ false } + } + + // Core-owned auth state stream: render `.pairing` as the pairing QR + // sheet, `.connected`/`.disconnected` as the account badge, and + // `.loginFailed` as a retryable error. When the user closes the pairing + // sheet, report it with `core.cancelLogin()`. + func authStateChanged(state: AuthState) { + DispatchQueue.main.async { /* render the state */ } + } + + func featureSupported(request: Data) throws -> Bool { false } + + 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 */ + } +} + +let bridge = MyBridge() +let runtimeConfig = RuntimeConfig( + productLabel: "my-product", + productId: "my-product.dot", + siteId: "host.example", + hostName: "My Host", + hostIcon: "https://host.example/icon.png", + peopleChainGenesisHash: Data(repeating: 0, count: 32), + pairingDeeplinkScheme: .polkadotApp +) +let core = try TrUAPIHostCore(bridge: bridge, runtimeConfig: runtimeConfig) +let endpoint = try core.startWsBridge() + +// Call these from host/platform observers so native subscriptions see updates +// after their immediate current item. +core.notifySessionStoreChanged() +core.notifyThemeChanged(theme: .dark) +core.notifyPreimageChanged(key: preimageKey, value: preimageBytesOrNil) +core.notifyChainResponse(connectionId: chainConnectionId, json: jsonRpcResponse) +core.notifyChainClosed(connectionId: chainConnectionId) + +let contentController = WKUserContentController() +let bootstrapScript = LocalhostBridgeBootstrap.script(port: endpoint.port, token: endpoint.token) +let userScript = WKUserScript( + source: bootstrapScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: true +) +contentController.addUserScript(userScript) + +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)`. + +## Linking the cdylib + +This package does not vendor `libtruapi_server` - integrators link a prebuilt static or dynamic library when building the app target. Typical workflow: + +```bash +cargo build -p truapi-server --release --features ws-bridge \ + --target aarch64-apple-ios +cargo build -p truapi-server --release --features ws-bridge \ + --target aarch64-apple-ios-sim +``` + +Then either bundle the `.a` files as a `.xcframework` and add it under "Frameworks, Libraries, and Embedded Content" in the app target, or link directly via `OTHER_LDFLAGS`. + +## Regenerating the bindings + +The ignored bindings under `Sources/TrUAPIHost/truapi_server.swift` and `Sources/truapi_serverFFI/include/` are produced from the workspace `uniffi-bindgen-cli`. Regenerate them before building or publishing the Swift package. The CLI emits `truapi_server.swift`, `truapi_serverFFI.h`, and `truapi_serverFFI.modulemap` into a single output directory; the modulemap is renamed to `module.modulemap` and the header is colocated under `Sources/truapi_serverFFI/include/` so SwiftPM's `systemLibrary` target picks them up. + +```bash +cargo build -p truapi-server --release --features ws-bridge +mkdir -p /tmp/uniffi-swift-out +cargo run -p uniffi-bindgen-cli -- generate \ + --library target/release/libtruapi_server.so \ + --language swift \ + --out-dir /tmp/uniffi-swift-out +cp /tmp/uniffi-swift-out/truapi_server.swift \ + ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +cp /tmp/uniffi-swift-out/truapi_serverFFI.h \ + ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +cp /tmp/uniffi-swift-out/truapi_serverFFI.modulemap \ + ios/truapi-host/Sources/truapi_serverFFI/include/module.modulemap +``` + +Or run `make uniffi` from the repo root. diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift new file mode 100644 index 00000000..8120af8e --- /dev/null +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -0,0 +1,482 @@ +// TrUAPIHost - iOS host adapter. +// +// The Rust core (compiled to `libtruapi_server`, surfaced through UniFFI in +// the sibling `truapi_server.swift` file) owns wire decoding, request +// routing, subscription lifecycle, and platform trait dispatch. +// +// This file exposes: +// +// * `HostBridge` - a Swift-friendly callback bundle the embedding app +// implements. It splits device and remote permissions, mirroring the +// `Permissions` platform trait in the Rust core. +// * `TrUAPIHostCore` - owning wrapper around the UniFFI-generated +// `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the +// core and exposes session + WS-bridge controls. +// * `LocalhostBridgeBootstrap` - small JS snippet that publishes the WS +// bridge endpoint to the product page so it can dial back in. +// +// Products running inside a `WKWebView` connect to the Rust core via the +// localhost WebSocket bridge. The bootstrap script publishes the URL +// (`ws://127.0.0.1:/?t=`); products feed it to +// `@parity/truapi`'s `createWebSocketProvider(url)`. + +import Foundation + +/// Package metadata. +public enum TrUAPIHost { + public static let version = "0.1.0" +} + +/// Deeplink scheme used when the Rust core builds SSO pairing payloads. +public enum PairingDeeplinkScheme: Sendable { + case polkadotApp + case polkadotAppDev + + fileprivate var native: NativePairingDeeplinkScheme { + switch self { + case .polkadotApp: + return .polkadotApp + case .polkadotAppDev: + return .polkadotAppDev + } + } +} + +/// Static product and pairing config supplied before the Rust core handles +/// product calls. One core instance represents one product identity. +/// +/// `hostName`, `hostIcon`, `hostVersion`, `platformType`, and +/// `platformVersion` describe the host to the wallet during SSO pairing. +/// `peopleChainGenesisHash` must be exactly 32 bytes. +public struct RuntimeConfig: Sendable { + public let productLabel: String + public let productId: String + public let siteId: String + public let hostName: String + public let hostIcon: String? + public let hostVersion: String? + public let platformType: String? + public let platformVersion: String? + public let peopleChainGenesisHash: Data + public let pairingDeeplinkScheme: PairingDeeplinkScheme + + public init( + productLabel: String, + productId: String, + siteId: String, + hostName: String, + hostIcon: String? = nil, + hostVersion: String? = nil, + platformType: String? = nil, + platformVersion: String? = nil, + peopleChainGenesisHash: Data, + pairingDeeplinkScheme: PairingDeeplinkScheme = .polkadotApp + ) { + self.productLabel = productLabel + self.productId = productId + self.siteId = siteId + self.hostName = hostName + self.hostIcon = hostIcon + self.hostVersion = hostVersion + self.platformType = platformType + self.platformVersion = platformVersion + self.peopleChainGenesisHash = peopleChainGenesisHash + self.pairingDeeplinkScheme = pairingDeeplinkScheme + } + + fileprivate var native: NativeRuntimeConfig { + NativeRuntimeConfig( + productLabel: productLabel, + productId: productId, + siteId: siteId, + hostName: hostName, + hostIcon: hostIcon, + hostVersion: hostVersion, + platformType: platformType, + platformVersion: platformVersion, + peopleChainGenesisHash: peopleChainGenesisHash, + pairingDeeplinkScheme: pairingDeeplinkScheme.native + ) + } +} + +/// Bootstrap helper for the native localhost WebSocket bridge that the Rust +/// core stands up via `NativeTrUApiCore.startWsBridge(bindPort:)` when the +/// cdylib is built with the `ws-bridge` feature. +public enum LocalhostBridgeBootstrap { + /// Returns a `