Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ jobs:
- name: Check committed iOS bindings are current
run: ./ios/truapi-host/scripts/sync-bindings.sh --check

# The provider ships its own UniFFI surface and its own committed
# bindings; `rebuild.sh` overwrites them in place, so only this check
# can fail on drift.
- name: Check committed TrUAPIProvider bindings are current
run: make provider-swift-check

ios-changes:
name: iOS change filter
runs-on: ubuntu-latest
Expand Down Expand Up @@ -197,7 +203,7 @@ jobs:
exit 0
fi
if git diff --name-only HEAD^1 HEAD \
| grep -qE '^(ios/|Package\.swift$|Makefile$|rust/crates/truapi-server/src/native)'; then
| grep -qE '^(ios/|Package\.swift$|Makefile$|rust/crates/truapi-server/src/native|rust/crates/truapi-provider/)'; then
echo "ios=true" >> "$GITHUB_OUTPUT"
else
echo "ios=false" >> "$GITHUB_OUTPUT"
Expand Down
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ PROVIDER_CDYLIB := $(UNIFFI_CDYLIB_DIR)/libtruapi_provider.so
endif

UNIFFI_SWIFT_TMP := target/uniffi-swift-out
PROVIDER_SWIFT_TMP := target/uniffi-provider-swift-check

uniffi: ## Generate Swift bindings from the truapi-server cdylib into target/uniffi-swift-out (consumed by ios/truapi-host/scripts/rebuild.sh).
$(CARGO) build -p truapi-server --profile codegen --features ws-bridge
Expand Down Expand Up @@ -215,6 +216,23 @@ android-publish-local: uniffi-kotlin ## Generate Kotlin bindings, then publish t
PROVIDER_KOTLIN_OUT := android/truapi-provider/src/main/kotlin/generated
PROVIDER_JNILIBS := android/truapi-provider/src/main/jniLibs

provider-swift: ## Generate the TrUAPIProvider Swift bindings into target/uniffi-provider-swift-out (no Xcode, no iOS targets).
$(CARGO) build -p truapi-provider --profile codegen --no-default-features --features uniffi
rm -rf $(PROVIDER_SWIFT_TMP)
mkdir -p $(PROVIDER_SWIFT_TMP)
$(CARGO) run -p uniffi-bindgen-cli -- generate \
--library $(PROVIDER_CDYLIB) \
--language swift \
--out-dir $(PROVIDER_SWIFT_TMP)

provider-swift-check: provider-swift ## Fail if the committed TrUAPIProvider bindings are stale.
@diff -u ios/truapi-provider/Sources/TrUAPIProvider/truapi_provider.swift \
$(PROVIDER_SWIFT_TMP)/truapi_provider.swift \
&& diff -u ios/truapi-provider/Sources/truapi_providerFFI/include/truapi_providerFFI.h \
$(PROVIDER_SWIFT_TMP)/truapi_providerFFI.h \
&& echo "Committed TrUAPIProvider bindings are current." \
|| { echo "Committed TrUAPIProvider bindings are stale: run 'make provider-ios'."; exit 1; }

provider-ios: ## Build the TrUAPIProvider Swift bindings + xcframework (adds --sim-only via SIM_ONLY=1).
bash ios/truapi-provider/scripts/rebuild.sh $(if $(SIM_ONLY),--sim-only,)

Expand Down
111 changes: 87 additions & 24 deletions android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@

package io.parity.truapi

import kotlinx.coroutines.CancellationException
import uniffi.truapi.HostDevicePermissionRequest
import uniffi.truapi.HostLocalStorageReadError
import uniffi.truapi.HostNavigateToError
import uniffi.truapi.HostFeatureSupportedRequest
import uniffi.truapi.HostPushNotificationRequest
import uniffi.truapi.RemotePermission
Expand Down Expand Up @@ -330,74 +333,134 @@ interface HostBridge {
val coreStorage: HostCoreStorage
}

// A host that throws an exception type its callback does not declare crosses
// the FFI as an unexpected callback error. The Rust core converts those rather
// than aborting, but the reason it receives is then a raw JVM description, so
// each adapter funnels host throws into the declared type here.

// Bounded: this reaches the product as the rejection reason, and a host
// message can carry a whole failed statement.
private fun hostRejectionReason(error: Throwable): String =
(error.message ?: error.toString()).take(HOST_REJECTION_REASON_MAX_CHARS)

private const val HOST_REJECTION_REASON_MAX_CHARS = 256

private inline fun <T> withHostRejection(operation: () -> T): T =
try {
operation()
} catch (rejection: HostRejection) {
throw rejection
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Throwable) {
throw HostRejection.Rejected(hostRejectionReason(error)).apply { initCause(error) }
}

private inline fun <T> withNavigateRejection(operation: () -> T): T =
try {
operation()
} catch (rejection: HostNavigateRejection) {
throw rejection
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Throwable) {
throw HostNavigateRejection.Navigate(
HostNavigateToError.Unknown(hostRejectionReason(error)),
).apply { initCause(error) }
}

private inline fun <T> withStorageException(operation: () -> T): T =
try {
operation()
} catch (storage: HostStorageException) {
throw storage
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Throwable) {
throw HostStorageException.Storage(
HostLocalStorageReadError.Unknown(hostRejectionReason(error)),
).apply { initCause(error) }
}

/**
* 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)
// The core declares this and `authStateChanged` infallible, so uniffi has
Comment thread
decrypto21 marked this conversation as resolved.
// no error type to convert a throw into and panics -- which aborts under
// `panic = "abort"`. Neither may let a host exception reach the FFI.
override fun onCoreLog(marker: String, detail: String) {
runCatching { bridge.onCoreLog(marker, detail) }
}

override suspend fun navigateTo(url: String) =
bridge.navigateTo(url)
withNavigateRejection { bridge.navigateTo(url) }

override suspend fun pushNotification(request: HostPushNotificationRequest): UInt =
bridge.pushNotification(request)
withHostRejection { bridge.pushNotification(request) }

override fun cancelNotification(id: UInt) =
bridge.cancelNotification(id)
withHostRejection { bridge.cancelNotification(id) }

override suspend fun devicePermission(request: HostDevicePermissionRequest): Boolean =
bridge.devicePermission(request)
withHostRejection { bridge.devicePermission(request) }

override suspend fun remotePermission(request: RemotePermission): Boolean =
bridge.remotePermission(request)

override fun authStateChanged(state: AuthState) =
bridge.authStateChanged(state)
withHostRejection { bridge.remotePermission(request) }

override fun authStateChanged(state: AuthState) {
try {
bridge.authStateChanged(state)
} catch (error: Throwable) {
runCatching {
bridge.onCoreLog("host.auth_state_changed.threw", error.stackTraceToString())
}
}
}

override fun coreStorageRead(key: ByteArray): ByteArray? =
bridge.coreStorage.read(key)
withHostRejection { bridge.coreStorage.read(key) }

override fun coreStorageWrite(key: ByteArray, value: ByteArray) =
bridge.coreStorage.write(key, value)
withHostRejection { bridge.coreStorage.write(key, value) }

override fun coreStorageClear(key: ByteArray) =
bridge.coreStorage.clear(key)
withHostRejection { bridge.coreStorage.clear(key) }

override fun chainConnect(genesisHash: ByteArray): UInt? =
bridge.chainConnect(genesisHash)
withHostRejection { bridge.chainConnect(genesisHash) }

override fun chainSend(connectionId: UInt, request: String) =
bridge.chainSend(connectionId, request)
withHostRejection { bridge.chainSend(connectionId, request) }

override fun chainClose(connectionId: UInt) =
bridge.chainClose(connectionId)
withHostRejection { bridge.chainClose(connectionId) }

override suspend fun confirmUserAction(review: UserConfirmationReview): Boolean =
bridge.confirmUserAction(review)
withHostRejection { bridge.confirmUserAction(review) }

override suspend fun lookupPreimage(key: ByteArray): ByteArray? =
bridge.lookupPreimage(key)
withHostRejection { bridge.lookupPreimage(key) }

override fun currentTheme(): HostThemeSubscribeItem =
bridge.currentTheme()
withHostRejection { bridge.currentTheme() }

override suspend fun featureSupported(request: HostFeatureSupportedRequest): Boolean =
bridge.featureSupported(request)
withHostRejection { bridge.featureSupported(request) }

override fun supportedChains(): HostChainSet =
bridge.supportedChains()
withHostRejection { bridge.supportedChains() }

override fun localStorageRead(key: String): ByteArray? =
bridge.storage.read(key)
withStorageException { bridge.storage.read(key) }

override fun localStorageWrite(key: String, value: ByteArray) =
bridge.storage.write(key, value)
withStorageException { bridge.storage.write(key, value) }

override fun localStorageClear(key: String) =
bridge.storage.clear(key)
withStorageException { bridge.storage.clear(key) }
}

/**
Expand Down
39 changes: 33 additions & 6 deletions ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ private final class ChatCallbackAdapter: NativeChatCallbacks, @unchecked Sendabl
} catch let error as HostRejection {
throw error
} catch {
throw HostRejection.Rejected(reason: error.localizedDescription)
throw HostRejection.Rejected(reason: hostRejectionReason(error))
}
}
}
Expand Down Expand Up @@ -673,7 +673,7 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable {
} catch let error as HostRejection {
throw error
} catch {
throw HostRejection.Rejected(reason: error.localizedDescription)
throw HostRejection.Rejected(reason: hostRejectionReason(error))
}
}

Expand All @@ -683,7 +683,7 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable {
} catch let error as HostRejection {
throw error
} catch {
throw HostRejection.Rejected(reason: error.localizedDescription)
throw HostRejection.Rejected(reason: hostRejectionReason(error))
}
}

Expand All @@ -693,7 +693,7 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable {
} catch let error as HostNavigateRejection {
throw error
} catch {
throw HostNavigateRejection.Navigate(.unknown(reason: error.localizedDescription))
throw HostNavigateRejection.Navigate(.unknown(reason: hostRejectionReason(error)))
}
}

Expand All @@ -703,7 +703,7 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable {
} catch let error as HostNavigateRejection {
throw error
} catch {
throw HostNavigateRejection.Navigate(.unknown(reason: error.localizedDescription))
throw HostNavigateRejection.Navigate(.unknown(reason: hostRejectionReason(error)))
}
}

Expand All @@ -713,7 +713,7 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable {
} catch let error as HostStorageError {
throw error
} catch {
throw HostStorageError.Storage(.unknown(reason: error.localizedDescription))
throw HostStorageError.Storage(.unknown(reason: hostRejectionReason(error)))
}
}
}
Expand Down Expand Up @@ -1100,6 +1100,33 @@ public final class TrUAPIHostCore: TrUAPIHostCoreProtocol {
}

}
/// Reason text for an error a host threw from a callback.
///
/// A value that is not a `LocalizedError` has no author-written description,
/// and `localizedDescription` renders it as "The operation couldn't be
/// completed. (Module.Type error 1.)" — which names the host's module and says
/// nothing about the failure. A plain value's `String(describing:)` prints its
/// stored properties, so only the type name crosses to the product.
private func hostRejectionReason(_ error: Error) -> String {
let reason: String
if let described = (error as? LocalizedError)?.errorDescription {
reason = described
} else if type(of: error) is NSError.Type {
// Foundation writes these, and the text describes the failure rather
// than the host's internals.
reason = error.localizedDescription
} else {
// A plain value's `String(describing:)` prints its stored properties,
// so only the type name crosses to the product.
reason = String(describing: type(of: error))
}
return String(reason.prefix(hostRejectionReasonMaxCharacters))
}

/// Bounded: the reason reaches the product, and a host message can carry a
/// whole failed statement.
private let hostRejectionReasonMaxCharacters = 256

private func customRendererStream(
_ subscribe: (CustomRendererStreamObserver) throws -> NativeCustomRendererSubscription
) throws -> AsyncThrowingStream<CustomRendererNode, Error> {
Expand Down
8 changes: 4 additions & 4 deletions ios/truapi-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ No Rust toolchain is needed: the xcframework carries the compiled crate, and the
Everything is generated from [`ffi.rs`](../../rust/crates/truapi-provider/src/ffi.rs):

- `ChainProvider` — construct **one per process** and share it. Every connection runs on the single embedded light client, so they share sync, peers, and warm state while keeping their own request queue and response stream. `connect(genesisHash:listener:)` resolves the network from the bundled catalog (relay wiring and statement-store placement included), so the 32-byte genesis hash is the only argument.
- `ChainMessageListener` — the host implements it; `onMessage(message:)` receives each JSON-RPC response and notification, `onClosed()` fires once the stream ends.
- `ChainMessageListener` — the host implements it; `onMessage(message:)` receives each JSON-RPC response and notification, `onClosed()` fires once the stream ends. Both may throw: a listener that throws stops the pump for that connection rather than being called again for every response, and an error it does not declare is reported as `.listener(reason:)` instead of aborting the process.
- `ChainConnection` — `send(request:)` queues a request, `disconnect()` tears the connection down.
- `ChainProviderError` — `.connect(reason:)` when the genesis is outside the catalog or the transport fails, `.badGenesis` when the hash is not 32 bytes.
- `ChainProviderError` — `.connect(reason:)` when the genesis is outside the catalog or the transport fails, `.badGenesis` when the hash is not 32 bytes, `.listener(reason:)` when the host's listener failed in a way it did not declare.

## Architecture

Expand All @@ -74,12 +74,12 @@ import Foundation
import TrUAPIProvider

final class Responses: ChainMessageListener, @unchecked Sendable {
func onMessage(message: String) {
func onMessage(message: String) throws {
// A JSON-RPC response or subscription notification, verbatim from smoldot.
DispatchQueue.main.async { /* decode and render */ }
}

func onClosed() {
func onClosed() throws {
DispatchQueue.main.async { /* the stream ended: drop the connection */ }
}
}
Expand Down
Loading