From 0235f623e39fd5fbe2f4bd0bc0ca26776ebeef63 Mon Sep 17 00:00:00 2001 From: pg Date: Sat, 1 Aug 2026 07:19:06 +0200 Subject: [PATCH 1/5] feat: integrate chat modality with shared core --- .gitignore | 1 + Makefile | 72 +- README.md | 56 + .../kotlin/io/parity/truapi/TrUAPIHost.kt | 43 + docs/design/chat-modality-product-sdk.md | 139 ++ ...odality-shared-core-implementation-plan.md | 137 ++ docs/design/chat-modality-shared-core.md | 490 +++++++ docs/design/modalities.md | 63 + hosts/android | 2 +- hosts/dotli | 2 +- hosts/ios | 2 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 379 ++++++ js/packages/truapi-host/src/runtime.ts | 3 + .../src/web/create-worker-host-runtime.ts | 7 +- .../src/web/worker-provider.test.ts | 35 +- js/packages/truapi/src/client.test.ts | 108 +- js/packages/truapi/src/client.ts | 21 + js/packages/truapi/src/transport.ts | 27 + package-lock.json | 3 - package.json | 3 +- playground/README.md | 9 + playground/package.json | 7 +- playground/vite.config.worker.ts | 15 + playground/worker/index.ts | 182 +++ playground/yarn.lock | 281 +++- .../truapi-codegen/src/rust/dispatcher.rs | 207 ++- .../truapi-codegen/src/rust/wire_table.rs | 2 +- rust/crates/truapi-codegen/src/rustdoc.rs | 62 +- rust/crates/truapi-codegen/src/ts.rs | 225 +++- .../truapi-codegen/src/ts/playground.rs | 4 +- .../truapi-codegen/tests/golden/dispatcher.rs | 37 +- .../tests/golden/host-callbacks.ts | 81 ++ .../truapi-codegen/tests/golden_rust_emit.rs | 24 +- rust/crates/truapi-macros/src/lib.rs | 33 +- rust/crates/truapi-platform/src/lib.rs | 64 +- .../crates/truapi-server/src/chain_runtime.rs | 125 +- rust/crates/truapi-server/src/core.rs | 3 +- rust/crates/truapi-server/src/dispatcher.rs | 156 ++- .../truapi-server/src/generated/dispatcher.rs | 48 +- rust/crates/truapi-server/src/host_core.rs | 382 ++++++ rust/crates/truapi-server/src/lib.rs | 11 +- .../truapi-server/src/middleware/execution.rs | 28 + .../truapi-server/src/middleware/mod.rs | 3 + rust/crates/truapi-server/src/native.rs | 1183 ++++++++++++++++- .../truapi-server/src/native_renderer.rs | 755 +++++++++++ rust/crates/truapi-server/src/runtime.rs | 290 ++-- rust/crates/truapi-server/src/runtime/chat.rs | 424 ++++++ .../truapi-server/src/runtime/services.rs | 22 +- .../truapi-server/src/runtime/signing_host.rs | 2 +- rust/crates/truapi-server/src/subscription.rs | 97 +- rust/crates/truapi-server/src/wasm.rs | 24 +- rust/crates/truapi/src/api/chat.rs | 33 +- rust/crates/truapi/src/lib.rs | 24 +- .../truapi/src/v01/chat/custom_renderer.rs | 28 +- rust/crates/truapi/src/versioned/chat.rs | 2 +- scripts/codegen.sh | 8 +- scripts/launch-ios-chat-playground.mjs | 417 ++++++ scripts/launch-ios-playground.mjs | 281 ++++ 58 files changed, 6927 insertions(+), 245 deletions(-) create mode 100644 docs/design/chat-modality-product-sdk.md create mode 100644 docs/design/chat-modality-shared-core-implementation-plan.md create mode 100644 docs/design/chat-modality-shared-core.md create mode 100644 docs/design/modalities.md create mode 100644 playground/vite.config.worker.ts create mode 100644 playground/worker/index.ts create mode 100644 rust/crates/truapi-server/src/middleware/execution.rs create mode 100644 rust/crates/truapi-server/src/middleware/mod.rs create mode 100644 rust/crates/truapi-server/src/native_renderer.rs create mode 100644 rust/crates/truapi-server/src/runtime/chat.rs create mode 100755 scripts/launch-ios-chat-playground.mjs create mode 100644 scripts/launch-ios-playground.mjs diff --git a/.gitignore b/.gitignore index 1e5ec19b..1b6f18b5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* # Build artifacts node_modules target +/artifacts/ # Gradle (Android workspace at repo root) /.gradle/ diff --git a/Makefile b/Makefile index 88cd1a2e..1253a02d 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-jni android-publish-local check-android-parity dotli-link dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test uniffi uniffi-kotlin ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local check-android-parity dotli-link dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer CARGO ?= cargo TRUAPI_PKG := js/packages/truapi @@ -41,6 +41,7 @@ setup: ## First-time setup: submodules, JS dependencies, generated artifacts. # that only exist after codegen.sh, which also builds the packages. npm ci --ignore-scripts ./scripts/codegen.sh + $(MAKE) uniffi cd $(PLAYGROUND) && yarn install --frozen-lockfile cd $(DOTLI) && bun install --frozen-lockfile $(MAKE) dotli-link @@ -103,6 +104,75 @@ uniffi: ## Regenerate Swift bindings from truapi-server cdylib. cp $(UNIFFI_SWIFT_TMP)/truapi_serverFFI.modulemap \ ios/truapi-host/Sources/truapi_serverFFI/include/module.modulemap +IOS_HOST := hosts/ios +IOS_DERIVED_DATA ?= $(IOS_HOST)/build/DerivedData +IOS_SIMULATOR_TARGET ?= aarch64-apple-ios-sim +IOS_CONFIGURATION ?= Debug +IOS_SWIFT_FLAGS ?= -DNIGHTLY -DW3S -DIOS_PASEO_E2E +IOS_BUNDLE ?= io.pcf.polkadotapp.develop +IOS_GOOGLE_SERVICE_PLIST ?= $(IOS_HOST)/polkadot-app/GoogleService/GoogleService-Info-Release.plist +IOS_PRODUCT_HOST ?= truapi-playground.dot +IOS_PRODUCT_URL ?= http://localhost:3100 +IOS_CHAT_PRODUCT_DIR ?= playground +IOS_CHAT_PRODUCT_HOST ?= truapi-playground.dot +IOS_CHAT_PRODUCT_NAME ?= TrUAPI Playground +IOS_CHAT_PRODUCT_URL ?= http://127.0.0.1:3100 +IOS_HOST_PLAYGROUND_DIR ?= ../host-playground +IOS_HOST_PLAYGROUND_HOST ?= host-playground.dot +IOS_HOST_PLAYGROUND_NAME ?= Host Playground +IOS_HOST_PLAYGROUND_URL ?= http://127.0.0.1:3101 +IOS_APP := $(abspath $(IOS_DERIVED_DATA)/Build/Products/$(IOS_CONFIGURATION)-iphonesimulator/polkadot-app.app) + +ios-build: uniffi ## Build matching Rust/Swift bindings and the TestFlight-configured iOS simulator app. + git submodule update --init --recursive $(IOS_HOST) + rustup target add $(IOS_SIMULATOR_TARGET) + $(CARGO) build -p truapi-server --release --features ws-bridge \ + --target $(IOS_SIMULATOR_TARGET) + cd $(IOS_HOST) && RUN_IN_CI=true xcodebuild \ + -project polkadot-app.xcodeproj \ + -scheme polkadot-app \ + -configuration $(IOS_CONFIGURATION) \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath $(abspath $(IOS_DERIVED_DATA)) \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + TRUAPI_SWIFT_FLAGS='$(IOS_SWIFT_FLAGS)' \ + build + cp "$(IOS_GOOGLE_SERVICE_PLIST)" "$(IOS_APP)/GoogleService-Info.plist" + codesign --force --sign - "$(IOS_APP)" + +ios-run: ios-build ## Build and launch the local TrUAPI playground in an iPhone simulator. + TRUAPI_IOS_E2E_APP="$(IOS_APP)" \ + TRUAPI_IOS_E2E_BUNDLE="$(IOS_BUNDLE)" \ + TRUAPI_IOS_E2E_PRODUCT_HOST="$(IOS_PRODUCT_HOST)" \ + TRUAPI_IOS_E2E_PRODUCT_URL="$(IOS_PRODUCT_URL)" \ + node scripts/launch-ios-playground.mjs + +ios-chat-run: ios-build ## Verify concurrent TrUAPI Playground App + Chat executions in an iPhone simulator. + TRUAPI_IOS_E2E_APP="$(IOS_APP)" \ + TRUAPI_IOS_E2E_BUNDLE="$(IOS_BUNDLE)" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_DIR="$(IOS_CHAT_PRODUCT_DIR)" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_HOST="$(IOS_CHAT_PRODUCT_HOST)" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_NAME="$(IOS_CHAT_PRODUCT_NAME)" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_URL="$(IOS_CHAT_PRODUCT_URL)" \ + node scripts/launch-ios-chat-playground.mjs + +ios-chat-host-playground-run: ios-build ## Verify Host Playground Chat through the workspace-linked TrUAPI client. + TRUAPI_IOS_E2E_APP="$(IOS_APP)" \ + TRUAPI_IOS_E2E_BUNDLE="$(IOS_BUNDLE)" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_DIR="$(abspath $(IOS_HOST_PLAYGROUND_DIR))" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_HOST="$(IOS_HOST_PLAYGROUND_HOST)" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_NAME="$(IOS_HOST_PLAYGROUND_NAME)" \ + TRUAPI_IOS_E2E_CHAT_PRODUCT_URL="$(IOS_HOST_PLAYGROUND_URL)" \ + TRUAPI_IOS_E2E_CHAT_ROOM_ID="host-playground-room" \ + TRUAPI_IOS_E2E_CHAT_EXPECTED_STARTUP_MESSAGE="" \ + TRUAPI_IOS_E2E_CHAT_EXPECT_CUSTOM_RENDERER="0" \ + TRUAPI_IOS_E2E_CHAT_SCREENSHOT="artifacts/host-playground-chat.png" \ + TRUAPI_IOS_E2E_CHAT_TRUAPI_DIR="$(abspath js/packages/truapi)" \ + node scripts/launch-ios-chat-playground.mjs + +ios-chat-all: ios-chat-run ios-chat-host-playground-run ## Run both local iOS Chat playground integrations. + UNIFFI_KOTLIN_OUT := android/truapi-host/src/main/kotlin/generated uniffi-kotlin: ## Regenerate Kotlin UniFFI bindings from the truapi-server cdylib. diff --git a/README.md b/README.md index b215ee1d..00e1dbd6 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,62 @@ yarn dev Open `https://dot.li/localhost:3000` inside the Polkadot Desktop Host. See [`playground/README.md`](playground/README.md) for deployment. +To build the iOS host and open the playground in Simulator: + +```bash +make ios-run +``` + +The target regenerates the UniFFI Swift bindings, builds the matching Rust +simulator library, and builds the `hosts/ios` app with the Nightly feature +flags and release Firebase app used by the Nightly TestFlight build. Native +Chat and the Paseo chain catalog come from the same Nightly Remote Config as +TestFlight. The executable keeps the development bundle and app-group identity +so Simulator can reuse its already registered wallet; keychain data cannot be +transferred to the production bundle. The simulator also adds the +`IOS_PASEO_E2E` conveniences needed to start on the real `browse.dot` Browse tab +and activate the embedded signing host. Embedded product host sessions use the +same Paseo People and Bulletin chains selected by the Nightly app +configuration. The launcher starts the playground at `http://localhost:3100` +when needed and uses that local source only after Browse opens +`truapi-playground.dot`. It refuses to launch if the local URL belongs to a +different app. Override the product, URL, or simulator with `IOS_PRODUCT_HOST`, +`IOS_PRODUCT_URL`, or `TRUAPI_IOS_E2E_DEVICE`. The simulator launch reuses the +wallet and registered username already stored by the iOS app. Opening a product +activates the embedded `truapi-host` signing-host session from that wallet; it +does not provision or pair a signer-bot user. + +To exercise the shared-core Chat path with the first-party TrUAPI Playground +worker, build and serve the local product, install its worker into the +simulator app's product storage, and launch both executables: + +```bash +make ios-chat-run +``` + +The launcher fails unless both the `App` and `Chat` WebSocket connections open, +the worker completes all six Chat API checks, a typed custom-renderer update +reaches the native widget, and a new SCALE-encoded `Echo: hello` reply reaches +CoreData. The worker receives `!echo hello` through `chat_action_subscribe` and +persists the reply through the Rust `ChatPlatform` adapter while the SPA uses +its separate connection to the same process runtime. Both executables build +against the workspace-linked `@parity/truapi`. Override the product source, +identity, SPA URL, room, input, or expected reply with +`IOS_CHAT_PRODUCT_DIR`, `IOS_CHAT_PRODUCT_HOST`, `IOS_CHAT_PRODUCT_URL`, +`TRUAPI_IOS_E2E_CHAT_ROOM_ID`, `TRUAPI_IOS_E2E_CHAT_MESSAGE`, or +`TRUAPI_IOS_E2E_CHAT_EXPECTED_REPLY`. + +The same harness can run the legacy Product SDK worker from the sibling +`host-playground` checkout. This target builds the current TrUAPI client, links +it over Host Playground's transitive `@parity/truapi`, then builds and runs +that product: + +```bash +make ios-chat-host-playground-run +``` + +Run both integrations with one iOS build using `make ios-chat-all`. + ## Regenerate the TypeScript client When the Rust trait surface changes: 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 index f7842a2f..bb706ea3 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -32,7 +32,10 @@ import uniffi.truapi_server.HostNavigateRejection import uniffi.truapi_server.HostRejection import uniffi.truapi_server.HostStorageException import uniffi.truapi_server.HostTheme +import uniffi.truapi_server.NativeChatRoom +import uniffi.truapi_server.NativeChatRoomRegistrationStatus import uniffi.truapi_server.NativePermissionAuthorizationStatus +import uniffi.truapi_server.NativeProductExecutionKind import uniffi.truapi_server.NativeRuntimeConfigException import uniffi.truapi_server.NativeTrUApiCore import uniffi.truapi_server.WsBridgeEndpoint @@ -57,6 +60,20 @@ enum class PairingDeeplinkScheme { } } +/** Trusted kind of executable attached to a product connection. */ +enum class ProductExecutionKind { + APP, + WIDGET, + CHAT; + + internal fun toNative(): NativeProductExecutionKind = + when (this) { + APP -> NativeProductExecutionKind.APP + WIDGET -> NativeProductExecutionKind.WIDGET + CHAT -> NativeProductExecutionKind.CHAT + } +} + /** * Static product and pairing config supplied before the Rust core handles * product calls. One core instance represents one product identity. @@ -69,6 +86,7 @@ enum class PairingDeeplinkScheme { */ data class RuntimeConfig( val productId: String, + val executionKind: ProductExecutionKind = ProductExecutionKind.APP, val hostName: String, val hostIcon: String? = null, val hostVersion: String? = null, @@ -83,6 +101,7 @@ data class RuntimeConfig( internal fun toNative(): UniFfiNativeRuntimeConfig = UniFfiNativeRuntimeConfig( productId = productId, + executionKind = executionKind.toNative(), hostName = hostName, hostIcon = hostIcon, hostVersion = hostVersion, @@ -99,6 +118,7 @@ data class RuntimeConfig( if (this === other) return true if (other !is RuntimeConfig) return false return productId == other.productId && + executionKind == other.executionKind && hostName == other.hostName && hostIcon == other.hostIcon && hostVersion == other.hostVersion && @@ -116,6 +136,7 @@ data class RuntimeConfig( override fun hashCode(): Int { var result = productId.hashCode() + result = 31 * result + executionKind.hashCode() result = 31 * result + hostName.hashCode() result = 31 * result + (hostIcon?.hashCode() ?: 0) result = 31 * result + (hostVersion?.hashCode() ?: 0) @@ -354,6 +375,28 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback override fun localStorageClear(key: String) = bridge.storage.clear(key) + + override fun chatSupported(): Boolean = false + + override fun chatCreateRoom( + roomId: String, + name: String, + icon: String, + ): NativeChatRoomRegistrationStatus = throw chatUnavailable() + + override fun chatPostTextMessage(roomId: String, text: String): String = + throw chatUnavailable() + + override fun chatPostCustomMessage( + roomId: String, + messageType: String, + payload: ByteArray, + ): String = throw chatUnavailable() + + override fun chatListRooms(): List = emptyList() + + private fun chatUnavailable(): HostRejection = + HostRejection.Rejected("native Chat adapter unavailable") } /** diff --git a/docs/design/chat-modality-product-sdk.md b/docs/design/chat-modality-product-sdk.md new file mode 100644 index 00000000..7d098fd0 --- /dev/null +++ b/docs/design/chat-modality-product-sdk.md @@ -0,0 +1,139 @@ +--- +title: "Chat Modality Product SDK" +type: design +status: proposed +created: 2026-07-31 +--- + +# Chat Modality Product SDK + +## Summary + +This document defines the product-side convenience API layered over the +generated TrUAPI Chat client. The underlying execution context, stream types, +message schemas, runtime policy, and native host contract are defined by +[Chat Modality on the Shared Rust Core](./chat-modality-shared-core.md). + +The Product SDK owns: + +- ordered Chat activation; +- custom renderer selection and React integration; +- widget-action routing to product callbacks; +- compatibility with the existing Triangle product API. + +It does not add another transport or lifecycle protocol. All communication uses +the generated TrUAPI connection. + +## Activation + +The high-level entrypoint is: + +```ts +await chat.start({ + onAction, + renderCustomMessage, +}); +``` + +`renderCustomMessage` is optional. `chat.start` performs the following ordered +setup: + +```text +worker calls chat.start(...) + -> install local action and renderer callbacks + -> when a renderer is supplied: + open custom_message_render_subscribe + -> optionally open chat_list_subscribe + -> open chat_action_subscribe last + -> resolve +``` + +Installing callbacks before opening streams ensures the SDK can handle values +as soon as Rust delivers them. Native actions that arrive before +`chat_action_subscribe` opens remain in Rust's bounded connection-scoped +buffer. A text-only Chat product skips the renderer streams. + +Module import starts the worker. `onBotStarted()` is not part of the new SDK. + +## Custom renderer adapter + +The SDK adapts the renderer request and response streams to the existing +product renderer model: + +```text +render item(message_id, message_type, payload) + -> select renderer by message_type + -> decode payload + -> mount the product React tree + -> send Update(message_id, CustomRendererNode) + +later React commit + -> send another Update for the same message_id +``` + +If no renderer accepts `message_type`, the SDK sends `Failed { message_id }`. +That failure affects only the corresponding native render stream. + +The React reconciler continues to produce complete `CustomRendererNode` trees. +Each `Update` replaces the previous native tree; products never manipulate +UIKit, SwiftUI, or Compose objects directly. + +Native owns the lifecycle of emitted widget trees and sends no per-message +cleanup event. The SDK retains its React roots and action callbacks for the +Chat connection and releases all of them when that connection closes. + +## Widget actions + +Interactive renderer nodes contain opaque action identifiers such as +`click_action` or `value_change_action`. The SDK uses one +`chat_action_subscribe` stream for the Chat execution: + +```text +ActionTriggered(message_id, action_id, payload) + -> find the renderer state for message_id + -> find the callback for action_id + -> invoke the callback + -> send any resulting renderer Update +``` + +The action identifier maps to an in-memory product callback; it is not a remote +callback reference. Input payloads such as text-field values are decoded before +the callback runs. + +## Compatibility and migration + +- Preserve `onCustomMessageRenderingRequest` as a facade over incoming renderer + stream items. +- Keep the existing React reconciler and `CustomRendererNode` serializer. +- Remove the Chat dependency on `container.js` and direct JavaScript + `evaluate` calls. +- Route every reply through the room id received in its action. Coin Flip must + use `action.roomId` rather than its single-room constant. + +The +[Coin Flip renderer](https://github.com/paritytech/coin-flip/blob/1158f534651537ed524db2a33735cc6841859757/worker/index.tsx#L31-L86) +is the reference migration: it decodes the stored result payload and renders +the flip count and result as a React tree. + +## Verification + +The Product SDK integration is complete when: + +- `chat.start` installs callbacks before opening any stream; +- the optional renderer streams are opened before the action subscription; +- text-only products open only the action subscription; +- incoming render items select the correct message renderer; +- multiple React commits send ordered updates for the same message; +- an unknown message type sends `Failed` without closing the renderer streams; +- widget actions reach the correct message renderer and local callback; +- native widget cleanup sends no product control message; +- closing the Chat connection unmounts all React roots and removes all action + callbacks; +- reconnecting establishes new streams and fresh renderer state; +- Coin Flip replies to the action's room id; +- no Chat path depends on `container.js` or JavaScript evaluation. + +## References + +- [Shared-core Chat protocol](./chat-modality-shared-core.md) +- [Shared-core implementation plan](./chat-modality-shared-core-implementation-plan.md) diff --git a/docs/design/chat-modality-shared-core-implementation-plan.md b/docs/design/chat-modality-shared-core-implementation-plan.md new file mode 100644 index 00000000..6f316327 --- /dev/null +++ b/docs/design/chat-modality-shared-core-implementation-plan.md @@ -0,0 +1,137 @@ +--- +title: "Chat Modality Shared Core Implementation Plan" +type: design +status: proposed +created: 2026-07-31 +--- + +# Chat Modality Shared Core Implementation Plan + +This plan implements the +[Chat Modality on the Shared Rust Core](./chat-modality-shared-core.md) design. +The design document is the source of truth for protocol, runtime, and native +host behavior. Product-side work is defined separately in the +[Chat Modality Product SDK](./chat-modality-product-sdk.md) design. + +## 1. Contract and generated bindings + +- Keep `custom_message_render_subscribe` product-initiated and change it from a + one-way subscription into paired request and response streams. +- Change its request into `Update` and `Failed` variants. +- Change its item into a render-request struct containing `message_id`, + `message_type`, and `payload`. +- Generate a minimal TypeScript `SubjectLike` for product-to-Rust + values and reuse `ObservableLike` for Rust-to-product values. +- Generalize Rust `Subscription` into a direction-neutral typed stream so it + can represent both halves of the operation. +- Use paired streams only for `custom_message_render_subscribe`; ordinary Chat + subscriptions remain one-way. +- Add service-level `required_execution` metadata and annotate the Chat trait + with `Chat`. +- Generate a service registration that wraps all Chat requests, subscriptions, + and stream pairs in the server's `ExecutionFilter`. +- Generate the TypeScript request/response-stream client and the corresponding + Rust dispatcher implementation. +- Generate native `CustomRendererNode` bindings. +- Add conformance tests for stream opening, values in both directions, + renderer updates, cancellation, disconnect, and recursive renderer nodes. + +The Chat service remains one logical product-to-host contract. Custom rendering +uses paired request and response streams supplied by the Rust Chat service. + +## 2. Shared runtime and execution connections + +- Refactor native construction into one process-owned `TrUAPIHostRuntime`. +- Add `open_product_execution(context)` returning a connection-specific + endpoint and control handle. +- Bind each WebSocket token to one immutable `ProductContext`. +- Add `truapi-server/src/middleware/execution.rs` and construct one + `ExecutionFilter` from the connection context. +- Reuse that filter for generated Chat dispatch and native-to-product + `ProductRuntime` methods. +- Keep one frame dispatcher and live-channel registry per connection while + sharing authentication, chain resources, storage services, and policy. +- Add the Chat service, action publisher, bounded connection-scoped action + buffer, renderer-stream routing, message-id correlation, native update + fan-out, connection-level cancellation, and resource limits. +- Atomically drain buffered actions in FIFO order when + `chat_action_subscribe` opens, then route subsequent actions directly. + +### Manifest example + +The +[Host Playground deployment config](https://github.com/paritytech/host-playground/blob/ab7ddb1476881a1ea3c77a4685f94a5ba60b6c72/bulletin-deploy.config.ts#L20-L42) +declares one `app` executable and one `worker` executable whose entrypoint is +`index.js` and whose `includes` value is `{ chat: true, pocket: false }`: + +```ts +executables: [ + { kind: "app", path: "./out" }, + { + kind: "worker", + path: "./out/worker", + entrypoint: "index.js", + includes: { chat: true, pocket: false }, + }, +]; +``` + +`bulletin-deploy` publishes this configuration as DotNS records. At runtime, +the host resolves those records and derives the immutable context for each +executable. + +## 3. iOS reference integration + +- Make `SPAInteractor` open an `App` execution for `index.html`. +- Make `ProductsScriptExecutor` open a `Chat` execution for `worker/index.js` + when its resolved DotNS metadata declares `includes.chat: true`. +- Implement `ChatPlatform` with the existing + `ChatExtensionDiscoverContext`/CoreData behavior. +- Replace `onUserMessage`, `dispatchEvent`, and `renderMessage` with calls on + the Chat connection's `ProductRuntime` handle. +- Delete `onBotStarted` and every chat-specific `evaluate` call. +- Pass generated `CustomRendererNode` values to native SwiftUI rather than + SCALE hex strings. + +## 4. Other hosts + +- Dotli maps a resolved DotNS worker with `includes.chat: true` to a `Chat` + execution and opens its connection through the WASM host runtime. +- Android implements `ChatPlatform` when native chat storage and UI are + available; until then Chat calls return `Unsupported`. +- The CLI may install an explicit in-memory `ChatPlatform` for end-to-end + tests. Its default runtime has no Chat adapter. + +## 5. Verification + +The integration is complete when the following pass without JavaScript +evaluation or `container.js`: + +- SPA and worker connect concurrently to one host runtime with distinct + contexts. +- An `App` request, subscription start, or renderer-stream open targeting Chat + is denied before its handler or `ChatPlatform` adapter runs. +- Native Chat actions and render requests cannot use an `App` connection. +- Native actions published before `chat_action_subscribe` opens are buffered + and delivered in FIFO order before subsequent live actions. +- Filling the startup action buffer returns `ProductRuntimeError::BufferFull`. +- Closing a Chat execution discards its buffer; a replacement connection does + not inherit those actions. +- Room creation reports `New` and `Exists` correctly. +- User text reaches the worker and the reply is persisted. +- Multiple rooms route replies through the action's room id. +- A stored custom message renders after app restart. +- One renderer stream pair multiplexes independent render instances for multiple + live messages. +- The renderer request stream accepts multiple typed `Update` values for the same + message. +- Native cell and widget cleanup emits no product control message. +- Closing the Chat view closes the associated product connection and renderer + stream. +- One action subscription delivers widget actions with the correct message and + action identifiers. +- An unknown message type sends `Failed` and affects only its render instance. +- Worker reconnect closes the old renderer streams and establishes a clean + replacement pair. +- Two products cannot observe each other's rooms, actions, or renders. +- A host without `ChatPlatform` reports `Unsupported` consistently. diff --git a/docs/design/chat-modality-shared-core.md b/docs/design/chat-modality-shared-core.md new file mode 100644 index 00000000..64710888 --- /dev/null +++ b/docs/design/chat-modality-shared-core.md @@ -0,0 +1,490 @@ +--- +title: "Chat Modality on the Shared Rust Core" +type: design +status: proposed +created: 2026-07-30 +--- + +# Chat Modality on the Shared Rust Core + +## Summary + +Chat products use the same TrUAPI execution path as SPA products. + +The visible app and Chat worker remain separate executions with separate +connections. Both connections terminate in the same process-owned Rust runtime +and use the host's native platform services. + +## Current architecture + +### Legacy iOS chat communication + +The working chat implementation in `polkadot-app-ios-v2` loads both +`container.js` and `worker/index.js` in a headless webview. The bridge has two +directions: + +- **Product to native:** the worker calls the Triangle Chat API. `container.js` + receives the SCALE request and forwards supported operations through + `callNative(method, params)` to `ContainerBridge`, which invokes the Swift + `ProductsNativeApi` implementation. +- **Native to product:** Swift evaluates globals installed by `container.js`, + such as `dispatchUserMessage(...)` and `dispatchChatAction(...)`. + `container.js` converts those calls into Chat actions delivered to the + worker's Triangle subscription. + +```text +Native iOS app container.js Product worker +(Chat UI / CoreData) (headless WKWebView) (worker/index.js) + | | | + | user submits "!echo hello" | | + | ProductBot.onTextMessage | | + | ProductsScriptExecutor evaluates | | + | dispatchUserMessage( | | + | roomId, text) | | + |--------------------------------->| | + | | creates MessagePosted: | + | | Text("!echo hello") | + | |--------------------------------->| + | | | subscribeAction receives + | | | the action and builds + | | | "Echo: hello" + | | | + | | chatManager.sendMessage( | + | | roomId, Text("Echo: hello")) | + | |<---------------------------------| + | | callNative( | + | | "chatSendTextMessage", ...) | + |<---------------------------------| | + | | | + | ProductsNativeApi persists | | + | the message in CoreData | | + | Native Chat UI displays | | + | "Echo: hello" | | + | | | +``` + +## Target architecture + +### Topology + +The target design has four structural rules: + +- the host process owns one long-lived Rust host runtime +- each executable gets its own connection and per-connection runtime +- `app/index.html` connects as `App`, while a Chat-enabled + `worker/index.js` connects as `Chat` +- both connections share authentication, storage, chain resources, and the + native Chat adapter + +On iOS, the existing `ProductTrUAPIHostRuntime` worker integration becomes the +Chat connection into the process-owned host runtime. It keeps the existing +worker launch path: inject the same WebSocket bootstrap as the SPA, then load +`worker/index.js`. The generated TrUAPI Chat API is available on that +`ProductContext(Chat)` connection. It replaces the legacy JavaScript globals +and `container.js` Chat bridge, which are removed. + +Chat is an API-backed modality with host-owned native UI. Its modality +declaration selects the Chat worker executable. The current DotNS +`includes.chat` declaration is the existing representation of that association +and may later map to the general `modalities.chat` manifest shape. + +The host derives each immutable context from resolved DotNS product and +executable records. The +[Host Playground deployment config](https://github.com/paritytech/host-playground/blob/ab7ddb1476881a1ea3c77a4685f94a5ba60b6c72/bulletin-deploy.config.ts#L20-L42) +is a concrete example. + +```text ++---------------+---------------+ +---------------+---------------+ +| App execution | | Chat execution | +| app/index.html | | worker/index.js | +| ProductContext(App) | | ProductContext(Chat) | ++---------------+---------------+ +---------------+---------------+ + | connection A | connection B + +---------------------+---------------------+ + | + | SCALE / TrUAPI + v + +---------------------------------------------------------+ + | Shared Rust HostRuntime | + | | + | ProductRuntime(App) ProductRuntime(Chat) | + | per-connection dispatch and live subscriptions | + | shared authentication, storage, and chain resources | + +----------------------------+----------------------------+ + ^ + | typed native bindings + v + +---------------------------------------------------------+ + | Native platform services | + | ChatPlatform -> native Chat UI and database | + | authentication, storage, and chain services | + +---------------------------------------------------------+ +``` + +Both executions use exactly the same SCALE protocol and generated client. They +differ only in: + +- which product executable was loaded +- the trusted context attached to their connection +- the long-lived protocol streams opened by the product executable +- whether they have a visible DOM + +### Trusted execution context + +The host creates the product connection with context derived from the resolved +DotNS product and executable metadata described above. The existing +`ProductContext` changes only by adding the trusted execution kind: + +```rust + pub struct ProductContext { + pub product_id: String, ++ /// Trusted kind derived from the resolved DotNS executable metadata. ++ pub execution_kind: ProductExecutionKind, + } +``` + +```rust +/// Classifies the product executable running on a connection. +pub enum ProductExecutionKind { + /// Visible application entrypoint, such as `app/index.html`. + App, + /// Host-embedded product widget entrypoint. + Widget, + /// Headless worker executable that provides the Chat modality. + Chat, +} +``` + +The context is bound to the transport endpoint before product code runs. On +iOS, the localhost WebSocket token resolves to this context when Rust accepts +the connection. The JavaScript product cannot submit or override its execution +kind. + +For this design, a DotNS executable declared as `kind: "worker"` with +`includes.chat: true` maps to `ProductExecutionKind::Chat`. Other worker modes +are outside the current scope and can add execution kinds when needed. + +The requirement is declared once on the API trait: + +```rust +#[truapi::service(required_execution = Chat)] +pub trait Chat: Send + Sync { + // requests, subscriptions, and paired request/response streams +} +``` + +`truapi-codegen` carries that service metadata into the generated server +registration. The generated registration is equivalent to: + +```rust +dispatcher.register_service( + chat, + ExecutionFilter::require(ProductExecutionKind::Chat), +); +``` + +`ExecutionFilter` lives in `truapi-server/src/middleware/execution.rs`. It is +created from the immutable `ProductContext` when the connection's +`ProductRuntime` is built and runs before any Chat handler or stream is opened. +A mismatch returns `Denied`; accepted calls continue to the Chat service's +authentication and `ChatPlatform` checks. + +The native `ProductRuntime` Chat entrypoints call the same filter before +publishing actions or render requests. Consequently, an `App` connection cannot +be used as the Chat connection, even when both executables belong to the same +product. + +### Execution lifecycle + +The host creates the Chat execution, loads its worker, and accepts the worker's +product-initiated subscriptions: + +```text +Native host Shared Rust HostRuntime Product worker + | | | + | create Chat execution | | + |--------------------------------->| | + | load worker/index.js | | + |-------------------------------------------------------------------->| + | | open TrUAPI connection | + | |<---------------------------------| + | | chat_action_subscribe | + | |<---------------------------------| + | | | +``` + +As a startup edge case, actions published before `chat_action_subscribe` opens +are held in a bounded connection-scoped FIFO and drained in order. Filling it +returns `ProductRuntimeError::BufferFull`; closing the connection discards it +rather than carrying actions into a replacement connection. + +The optional renderer and room-list streams are independent; their setup order +is defined by the Product SDK. There is at most one active Chat execution per +product in a host process. + +Closing or replacing an execution terminates all of its protocol streams. +Reconnection creates a new execution, reloads the worker module, and +establishes fresh streams. + +Durable redelivery, acknowledgement, and bot inbox semantics remain outside +this design. + +## Chat flows + +### Custom rendering + +The existing `custom_message_render_subscribe` operation remains initiated by +the product. It now has a product-to-Rust request stream and a Rust-to-product +response stream: + +| Event | Direction | Value | +| -------------------- | --------------- | -------------------------------------- | +| Open stream | Product -> Rust | call `custom_message_render_subscribe` | +| Ask for native UI | Rust -> product | render item | +| Return or replace UI | Product -> Rust | request `Update` | +| Reject render | Product -> Rust | request `Failed` | + +The product opens the operation by subscribing to its response stream. Opening +it carries no application request value. Other Chat subscriptions remain +ordinary one-way subscriptions. + +The resulting Rust API shape is: + +```rust +/// Serves custom-message rendering over paired request and response streams. +async fn custom_message_render_subscribe( + &self, + _cx: &CallContext, + requests: Subscription, +) -> Subscription; + +/// Values sent from the product to Rust on the renderer request stream. +pub enum ProductChatCustomMessageRenderSubscribeRequest { + /// Replaces the native tree for one active render instance. + Update { + /// Identifier supplied by the host in the corresponding render item. + message_id: String, + + /// Complete replacement tree produced by the product renderer. + node: CustomRendererNode, + }, + + /// Reports that the product cannot render one requested message. + Failed { + /// Identifier supplied by the host in the corresponding render item. + message_id: String, + }, +} + +/// Render request sent from Rust to the product on the renderer response stream. +pub struct ProductChatCustomMessageRenderSubscribeItem { + /// Stable identifier used to correlate updates and widget actions. + pub message_id: String, + + /// Product-defined discriminator used to select a renderer. + pub message_type: String, + + /// Stored product-defined message payload. + pub payload: Vec, +} +``` + +In the target API, `Subscription` is a direction-neutral typed stream. The +`requests` parameter is the product-to-Rust stream, while the returned +`Subscription` is the Rust-to-product stream. Both are scoped to the same +connection and operation. + +The generated TypeScript API reuses `ObservableLike` for responses and adds a +minimal `SubjectLike` for requests: + +```ts +export interface SubjectLike { + next: (value: T) => void; +} + +export class ChatClient { + customMessageRenderSubscribe(): { + requests: SubjectLike; + responses: ObservableLike; + }; +} +``` + +Usage remains close to an existing TrUAPI subscription: + +```ts +const { requests, responses } = truapi.chat.customMessageRenderSubscribe(); + +responses.subscribe({ + next(item) { + const node = render(item.messageType, item.payload); + + requests.next({ + tag: "Update", + value: { messageId: item.messageId, node }, + }); + }, +}); +``` + +Subscribing to `responses` registers the renderer. A text-only Chat product +does not create these streams. Closing the Chat connection closes both; no +application-level start or stop value is required. + +```text +Native platform Shared Rust HostRuntime Product worker +(Chat UI) (ProductRuntime) (worker/index.js) + | | | + | | open renderer streams | + | |<------------------------------------| + | | renderer streams are active | + | | | + | display Custom(message_id, | | + | message_type, payload) | | + | render_custom_message(...) | | + |------------------------------------->| | + | | render item { message_id, | + | | message_type, payload } | + | |------------------------------------>| + | | | decode payload + | | | produce renderer tree + | | Update { message_id, node } | + | |<------------------------------------| + | typed CustomRendererNode | | + |<-------------------------------------| | + | repaint native widget | | + | | | + | | | renderer state changes + | | Update { message_id, new_node } | + | |<------------------------------------| + | typed replacement node | | + |<-------------------------------------| | + | | | + | dismiss Chat UIView | | + | destroy native widget trees | close Chat connection | + |------------------------------------->|------------------------------------>| + | | | discard all renderer state + | | | +``` + +The stored `message_id` correlates each render item, all of its `Update` values, +and widget actions. Native owns the lifecycle of cells, observers, and the +latest widget tree. Removing a native observer sends no product control +message. Product-side renderer state is scoped to the Chat connection and is +discarded when it closes. `Failed` ends only the matching native render stream; +it does not close the shared renderer streams. + +`CustomRendererNode` crosses the native boundary as a generated Swift or Kotlin +type. SCALE hex strings and separately maintained native decoders are not part +of the interface. + +## Native host contract + +When the native host opens a Chat worker connection, `truapi-server` returns a +concrete `ProductRuntime` bound to that connection. Swift, Kotlin, or another +host binding retains this handle and uses it to send native events through +Rust to the product worker. It is not part of the product-facing TrUAPI API. + +```rust +impl ProductRuntime { + fn publish_chat_action( + &self, + action: HostChatActionSubscribeItem, + ) -> Result<(), ProductRuntimeError>; + + fn render_custom_message( + &self, + message_id: String, + message_type: String, + payload: Vec, + ) -> Result, ProductRuntimeError>; +} +``` + +`publish_chat_action` covers both `MessagePosted` and `ActionTriggered` by +accepting the existing action enum. For example: + +```text +Native Chat UI ProductRuntime Product worker + | | | + | user types "hello" | | + | publish_chat_action(...) | | + |----------------------------->| | + | | MessagePosted(Text("hello")) | + | |------------------------------>| + | | via chat_action_subscribe | + | | | +``` + +`render_custom_message` is called when a native cell needs to display a stored +custom message. It publishes a render item on the active product-initiated +response stream and returns a native stream fed by matching `Update` values +from the request stream. Native owns that stream's observers and rendered +views; dropping them sends nothing to the product. + +In the opposite direction, product-originated room, message, and room-list +calls flow from Rust through the optional `ChatPlatform` adapter. Hosts install +it only when they provide native chat storage and UI: + +```rust +pub trait ChatPlatform: Send + Sync { + async fn create_room( + &self, + product: &ProductContext, + request: CreateRoomRequest, + ) -> Result; + + async fn post_message( + &self, + product: &ProductContext, + request: PostMessageRequest, + ) -> Result; + + fn room_list( + &self, + product: &ProductContext, + ) -> PlatformStream; +} +``` + +`ChatPlatform` remains the adapter Rust invokes for product requests that +mutate or observe native Chat storage. + +`ChatPlatform` identifies rooms by the trusted `ProductContext.product_id` +together with the product-local `room_id`. It must never treat a +product-supplied `room_id` as a globally authoritative native database key. +Message lookup and mutation are constrained to the same product namespace. + +Exact UniFFI async-stream mechanics may use a generated callback handle, but +the values crossing the boundary remain generated typed values. + +## Failure behavior + +Policy and renderer-stream validation are enforced in Rust: + +| Condition | Result | +| ------------------------------------------------- | ---------------------------------------------------- | +| Ordinary Chat call is not from a Chat execution | `CallError::Denied` | +| Ordinary Chat call has no `ChatPlatform` adapter | `CallError::Unsupported` | +| Ordinary Chat call is unauthenticated | Existing authentication failure | +| Native action targets a closed execution | `ProductRuntimeError::Closed` | +| Native action fills the startup buffer | `ProductRuntimeError::BufferFull` | +| Renderer is requested on a non-Chat connection | `ProductRuntimeError::Denied` | +| Chat execution did not open renderer streams | Rendering reports `ProductRuntimeError::Unsupported` | +| Product sends `Failed` for the message type | Only that native render stream ends | +| Renderer node is malformed or exceeds host limits | Only that native render stream fails | +| A renderer-stream value cannot be decoded | The renderer streams and render instances fail | +| Product disconnects during rendering | Its renderer streams and native streams terminate | + +Every call is scoped from `ProductContext`; product-supplied ids are +never used to select another product connection. + +## References + +- [Implementation plan](./chat-modality-shared-core-implementation-plan.md) +- [Product SDK design](./chat-modality-product-sdk.md) +- [TrUAPI Protocol Design](./truapi-protocol.md) +- [`Chat` Rust trait](../../rust/crates/truapi/src/api/chat.rs) +- [`CustomRendererNode` types](../../rust/crates/truapi/src/v01/chat/custom_renderer.rs) +- [Current iOS worker executor](../../hosts/ios/Packages/Products/Sources/Products/Services/ProductsScriptExecutor.swift) +- [Current iOS TrUAPI bridge](../../hosts/ios/Packages/Products/Sources/Products/Services/ProductTrUAPIHostBridge.swift) diff --git a/docs/design/modalities.md b/docs/design/modalities.md new file mode 100644 index 00000000..0e9fed00 --- /dev/null +++ b/docs/design/modalities.md @@ -0,0 +1,63 @@ +# Modalities + +Source: [Google Doc](https://docs.google.com/document/d/1YX-OVNqRNJ-9qDCYHGwZstW_4NxnciqQ6UvG4_kO0x0/edit) +Owner: Torsten Stüber · Classification: Internal · Last modified: 2026-07-31 + +In this document I propose a rough sketch of a framework for how a product defines and implements modalities. This applies to + +* SPA Modality +* Input Modality +* Pocket Modality +* Funding Modality + +Extension modalities are more generic and out of scope for the purposes of this document. + +### Manifest + +Each product defines its modalities in the product manifest. I propose a new entry in the manifest called `"modalities"`. Its value is a JSON object with the following members, each of them optional: + +* `"spa"` +* `"pocket"` +* `"input"` +* `"funding"` +* (and other entries for new modalities we introduce or support later) + +A product only declares modalities it supports and provides. + +The value for each declared modality is a JSON object with entries `"views"` and `"api"`. The structure of the values of `"views"` and `"api"` depends on the modality type (see below). If the modality type does not define any `"views"` or `"api"`, then the respective field can be left out. + +The `"views"` of the modality define different views the modality has to provide. The shape of its value looks as follows: + +* for `"spa"`: `{ "spa": }` +* for `"pocket"`: `{ "card": , "expanded": }` +* for `"input"`: empty object +* for `"funding"`: empty object + +The `ViewDefinition` refers to a webapp bundle (either via CID or in the product bundle retrieved from BC), similar to an SPA modality. + +* **Question**: which option makes more sense? +* **Question**: should it also contain the view size? I think that products should define their view in a responsive way (adapt to any size) and the host decides the size of the view (i.e., to standardize the card size for pocket) + +The `"api"` definitions of a modality are entry points into logic the modality provides. The shape of its value looks as follows: + +* for `"spa"`: empty object +* for `"pocket"`: empty object +* for `"input"`: `{ "acceptsInput": , "search": }` +* for `"funding"`: `{ "acceptFunding": }` + +The `ApiDefinition` is a name of the worker JS executable. This executable needs to be provided as part of the product bundle whenever the product defines a modality that has a nonempty `"api"` definition. + +The host will call the api function of defined modalities whenever it needs to execute certain actions, for example when it provides the input or funding UI to the user. When and how this happens is out of scope for this document. + +### Sandbox + +The JS code of each view and the worker JS code need to be executed within the Trinity Sandbox and have to use the TrUAPI. + +### Shared Data + +**Open Questions:** + +* Is local storage shared across all modalities of a product? +* Are product accounts shared across all modalities of a product? + +I propose to answer both questions with "yes" because whenever the worker JS code calls a TrUAPI function, it is not immediately clear what modality entry point led to the execution of this TrUAPI function and therefore it is not obvious how the TrUAPI can easily distinguish between modalities. diff --git a/hosts/android b/hosts/android index 74672b8f..07bac664 160000 --- a/hosts/android +++ b/hosts/android @@ -1 +1 @@ -Subproject commit 74672b8f1d76ea203a6f57e99c389a272cff63b3 +Subproject commit 07bac6643b07b8a98f07843e71ff481e09b7c279 diff --git a/hosts/dotli b/hosts/dotli index 7921ce41..4bb7a023 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 7921ce413a4a1661b36c3f5032d91287a8f160bf +Subproject commit 4bb7a0237f6d2dd7b6c8c9e88c8e23aba0b50659 diff --git a/hosts/ios b/hosts/ios index f11bd551..7fe8757c 160000 --- a/hosts/ios +++ b/hosts/ios @@ -1 +1 @@ -Subproject commit f11bd551cf20a5d39a81a61751cf04bd194cdffc +Subproject commit 7fe8757c1dc759f559365351d0a53daa9e0e1404 diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index f25295d0..680d9392 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -42,6 +42,21 @@ public enum PairingDeeplinkScheme: Sendable { } } +/// Trusted kind of executable attached to a product connection. +public enum ProductExecutionKind: Sendable, Equatable { + case app + case widget + case chat + + fileprivate var native: NativeProductExecutionKind { + switch self { + case .app: .app + case .widget: .widget + case .chat: .chat + } + } +} + /// Static product and pairing config supplied before the Rust core handles /// product calls. One core instance represents one product identity. /// @@ -51,6 +66,7 @@ public enum PairingDeeplinkScheme: Sendable { /// exactly 32 bytes. public struct RuntimeConfig: Sendable { public let productId: String + public let executionKind: ProductExecutionKind public let hostName: String public let hostIcon: String? public let hostVersion: String? @@ -64,6 +80,7 @@ public struct RuntimeConfig: Sendable { public init( productId: String, + executionKind: ProductExecutionKind = .app, hostName: String, hostIcon: String? = nil, hostVersion: String? = nil, @@ -76,6 +93,7 @@ public struct RuntimeConfig: Sendable { pairingDeeplinkScheme: PairingDeeplinkScheme = .polkadotApp ) { self.productId = productId + self.executionKind = executionKind self.hostName = hostName self.hostIcon = hostIcon self.hostVersion = hostVersion @@ -91,6 +109,7 @@ public struct RuntimeConfig: Sendable { fileprivate var native: NativeRuntimeConfig { NativeRuntimeConfig( productId: productId, + executionKind: executionKind.native, hostName: hostName, hostIcon: hostIcon, hostVersion: hostVersion, @@ -105,6 +124,73 @@ public struct RuntimeConfig: Sendable { } } +/// Immutable process-wide configuration shared by all product executions. +public struct HostRuntimeConfig: Sendable, Equatable { + 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 bulletinChainGenesisHash: Data + public let localSessionSecret: Data? + public let localSessionLiteUsername: String? + + public init( + hostName: String, + hostIcon: String? = nil, + hostVersion: String? = nil, + platformType: String? = nil, + platformVersion: String? = nil, + peopleChainGenesisHash: Data, + bulletinChainGenesisHash: Data, + localSessionSecret: Data? = nil, + localSessionLiteUsername: String? = nil + ) { + self.hostName = hostName + self.hostIcon = hostIcon + self.hostVersion = hostVersion + self.platformType = platformType + self.platformVersion = platformVersion + self.peopleChainGenesisHash = peopleChainGenesisHash + self.bulletinChainGenesisHash = bulletinChainGenesisHash + self.localSessionSecret = localSessionSecret + self.localSessionLiteUsername = localSessionLiteUsername + } + + fileprivate var native: NativeHostRuntimeConfig { + NativeHostRuntimeConfig( + hostName: hostName, + hostIcon: hostIcon, + hostVersion: hostVersion, + platformType: platformType, + platformVersion: platformVersion, + peopleChainGenesisHash: peopleChainGenesisHash, + bulletinChainGenesisHash: bulletinChainGenesisHash, + localSessionSecret: localSessionSecret, + localSessionLiteUsername: localSessionLiteUsername + ) + } +} + +/// Host-selected identity and trusted kind for one executable connection. +public struct ProductExecutionConfig: Sendable, Equatable { + public let productId: String + public let executionKind: ProductExecutionKind + + public init(productId: String, executionKind: ProductExecutionKind) { + self.productId = productId + self.executionKind = executionKind + } + + fileprivate var native: NativeProductExecutionConfig { + NativeProductExecutionConfig( + productId: productId, + executionKind: executionKind.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. @@ -131,6 +217,10 @@ public enum LocalhostBridgeBootstrap { onmessageerror: null, postMessage: function(message) { + if (!started) { + port.start(); + } + if (socket && socket.readyState === WebSocket.OPEN) { socket.send(message); } else { @@ -322,6 +412,26 @@ public protocol HostBridge: AnyObject, Sendable { /// Core-owned host-private storage for auth session, pairing identity, /// and persisted permission decisions. var coreStorage: HostCoreStorageBackend { get } + + /// Whether this host installs native Chat storage and UI callbacks. + var supportsChat: Bool { get } + + /// Create or resolve a native product Chat room. + func chatCreateRoom(roomId: String, name: String, icon: String) throws + -> NativeChatRoomRegistrationStatus + + /// Persist a text message in native Chat storage. + func chatPostTextMessage(roomId: String, text: String) throws -> String + + /// Persist a custom message in native Chat storage. + func chatPostCustomMessage( + roomId: String, + messageType: String, + payload: Data + ) throws -> String + + /// Return the current product-scoped native Chat rooms. + func chatListRooms() throws -> [NativeChatRoom] } public extension HostBridge { @@ -336,6 +446,25 @@ public extension HostBridge { func confirmUserAction(review: Data) throws -> Bool { false } func lookupPreimage(key: Data) throws -> Data? { nil } func currentTheme() throws -> HostTheme { .dark } + var supportsChat: Bool { false } + func chatCreateRoom( + roomId: String, + name: String, + icon: String + ) throws -> NativeChatRoomRegistrationStatus { + throw HostRejection.Rejected(reason: "native Chat adapter unavailable") + } + func chatPostTextMessage(roomId: String, text: String) throws -> String { + throw HostRejection.Rejected(reason: "native Chat adapter unavailable") + } + func chatPostCustomMessage( + roomId: String, + messageType: String, + payload: Data + ) throws -> String { + throw HostRejection.Rejected(reason: "native Chat adapter unavailable") + } + func chatListRooms() throws -> [NativeChatRoom] { [] } } /// Adapter that bridges the public `HostBridge` to the generated UniFFI @@ -466,6 +595,44 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } + func chatSupported() -> Bool { + bridge.supportsChat + } + + func chatCreateRoom( + roomId: String, + name: String, + icon: String + ) throws -> NativeChatRoomRegistrationStatus { + try withHostRejection { + try bridge.chatCreateRoom(roomId: roomId, name: name, icon: icon) + } + } + + func chatPostTextMessage(roomId: String, text: String) throws -> String { + try withHostRejection { + try bridge.chatPostTextMessage(roomId: roomId, text: text) + } + } + + func chatPostCustomMessage( + roomId: String, + messageType: String, + payload: Data + ) throws -> String { + try withHostRejection { + try bridge.chatPostCustomMessage( + roomId: roomId, + messageType: messageType, + payload: payload + ) + } + } + + func chatListRooms() throws -> [NativeChatRoom] { + try withHostRejection { try bridge.chatListRooms() } + } + private func withHostRejection(_ operation: () throws -> T) throws -> T { do { return try operation() @@ -497,6 +664,142 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } +/// Process-owned Rust host runtime. Product executables open independent +/// connections from this object and share its authentication and core services. +public final class TrUAPIHostRuntime: @unchecked Sendable { + private let inner: NativeTrUApiHostRuntime + private let callbackRetainer: HostCallbacks + + public init(bridge: HostBridge, runtimeConfig: HostRuntimeConfig) throws { + let adapter = HostCallbackAdapter(bridge: bridge) + callbackRetainer = adapter + inner = try NativeTrUApiHostRuntime.withRuntimeConfig( + callbacks: adapter, + runtimeConfig: runtimeConfig.native + ) + LiveSessionStoreForwarder.register(self) + notifySessionStoreChanged() + } + + deinit { + LiveSessionStoreForwarder.unregister(self) + } + + /// Open one executable connection with a host-assigned immutable context. + public func openProductExecution( + bridge: HostBridge, + configuration: ProductExecutionConfig + ) throws -> TrUAPIProductExecution { + let adapter = HostCallbackAdapter(bridge: bridge) + let execution = try inner.openProductExecution( + callbacks: adapter, + executionConfig: configuration.native + ) + return TrUAPIProductExecution(inner: execution, callbackRetainer: adapter) + } + + public func disconnect() { + inner.disconnect() + } + + public func notifySessionStoreChanged() { + inner.notifySessionStoreChanged() + } + + public func cancelLogin() { + inner.cancelLogin() + } + + public func activateLocalSession(secret: Data, liteUsername: String? = nil) throws { + try inner.activateLocalSession(secret: secret, liteUsername: liteUsername) + } + + public func notifyChainResponse(connectionId: UInt32, json: String) { + inner.notifyChainResponse(connectionId: connectionId, json: json) + } + + public func notifyChainClosed(connectionId: UInt32) { + inner.notifyChainClosed(connectionId: connectionId) + } +} + +/// One App, Widget, or Chat executable connected to a shared host runtime. +public final class TrUAPIProductExecution: @unchecked Sendable { + private let inner: NativeProductExecution + private let callbackRetainer: HostCallbacks + + fileprivate init(inner: NativeProductExecution, callbackRetainer: HostCallbacks) { + self.inner = inner + self.callbackRetainer = callbackRetainer + } + + deinit { + inner.close() + } + + public func startWsBridge(bindPort: UInt16 = 0) throws -> WsBridgeEndpoint { + try inner.startWsBridge(bindPort: bindPort) + } + + public func stopWsBridge() { + inner.stopWsBridge() + } + + public func close() { + inner.close() + } + + public func publishChatAction(_ action: NativeChatAction) throws { + try inner.publishChatAction(action: action) + } + + public func renderCustomMessage( + messageId: String, + messageType: String, + payload: Data + ) throws -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream( + of: NativeCustomRendererNode.self + ) + let observer = CustomRendererStreamObserver(continuation: continuation) + let subscription = try inner.renderCustomMessage( + messageId: messageId, + messageType: messageType, + payload: payload, + observer: observer + ) + continuation.onTermination = { @Sendable _ in + subscription.cancel() + } + return stream + } + + public func permissionAuthorizationStatus( + request: Data + ) throws -> NativePermissionAuthorizationStatus { + try inner.permissionAuthorizationStatus(payload: request) + } + + public func setPermissionAuthorizationStatus( + request: Data, + status: NativePermissionAuthorizationStatus + ) throws { + try inner.setPermissionAuthorizationStatus(payload: request, status: status) + } + + public func notifyThemeChanged(theme: HostTheme) { + inner.notifyThemeChanged(theme: theme) + } + + public func notifyPreimageChanged(key: Data, value: Data?) { + inner.notifyPreimageChanged(key: key, value: value) + } + + public func notifyChatRoomsChanged(rooms: [NativeChatRoom]) { + inner.notifyChatRoomsChanged(rooms: rooms) + } +} + /// Owning wrapper around the Rust-backed `NativeTrUApiCore`. Holds the bridge /// adapter alive for the lifetime of the core and exposes session + /// WS-bridge controls. @@ -539,6 +842,33 @@ public final class TrUAPIHostCore { inner.stopWsBridge() } + /// Publish a native Chat action to this core's connected Chat worker. + public func publishChatAction(_ action: NativeChatAction) throws { + try inner.publishChatAction(action: action) + } + + /// Stream typed replacement trees for one stored custom Chat message. + public func renderCustomMessage( + messageId: String, + messageType: String, + payload: Data + ) throws -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream( + of: NativeCustomRendererNode.self + ) + let observer = CustomRendererStreamObserver(continuation: continuation) + let subscription = try inner.renderCustomMessage( + messageId: messageId, + messageType: messageType, + payload: payload, + observer: observer + ) + continuation.onTermination = { @Sendable _ in + subscription.cancel() + } + return stream + } + /// Core-owned logout/disconnect path. Best-effort notifies the SSO peer, /// clears in-memory session state, clears the persisted session via /// ``HostBridge/coreStorage``, and broadcasts `Disconnected` to active @@ -600,6 +930,28 @@ public final class TrUAPIHostCore { public func notifyChainClosed(connectionId: UInt32) { inner.notifyChainClosed(connectionId: connectionId) } + + /// Push a complete replacement of the native Chat room list to active + /// product subscriptions. + public func notifyChatRoomsChanged(rooms: [NativeChatRoom]) { + inner.notifyChatRoomsChanged(rooms: rooms) + } +} + +private final class CustomRendererStreamObserver: NativeCustomRendererObserver, @unchecked Sendable { + private let continuation: AsyncThrowingStream.Continuation + + init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation + } + + func onUpdate(node: NativeCustomRendererNode) { + continuation.yield(node) + } + + func onComplete() { + continuation.finish() + } } private final class WeakTrUAPIHostCore { @@ -610,9 +962,30 @@ private final class WeakTrUAPIHostCore { } } +private final class WeakTrUAPIHostRuntime { + weak var value: TrUAPIHostRuntime? + + init(_ value: TrUAPIHostRuntime) { + self.value = value + } +} + private enum LiveSessionStoreForwarder { private static let lock = NSLock() private static var cores: [ObjectIdentifier: WeakTrUAPIHostCore] = [:] + private static var runtimes: [ObjectIdentifier: WeakTrUAPIHostRuntime] = [:] + + static func register(_ runtime: TrUAPIHostRuntime) { + lock.lock() + runtimes[ObjectIdentifier(runtime)] = WeakTrUAPIHostRuntime(runtime) + lock.unlock() + } + + static func unregister(_ runtime: TrUAPIHostRuntime) { + lock.lock() + runtimes.removeValue(forKey: ObjectIdentifier(runtime)) + lock.unlock() + } static func register(_ core: TrUAPIHostCore) { lock.lock() @@ -628,14 +1001,20 @@ private enum LiveSessionStoreForwarder { static func notifySessionStoreChanged() { let liveCores: [TrUAPIHostCore] + let liveRuntimes: [TrUAPIHostRuntime] lock.lock() cores = cores.filter { $0.value.value != nil } + runtimes = runtimes.filter { $0.value.value != nil } liveCores = cores.values.compactMap(\.value) + liveRuntimes = runtimes.values.compactMap(\.value) lock.unlock() for core in liveCores { core.notifySessionStoreChanged() } + for runtime in liveRuntimes { + runtime.notifySessionStoreChanged() + } } } diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index 8c4b0fb9..5d288bfc 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -3,6 +3,7 @@ import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-call import type { CoreAdmin, CoreStorageKey, + ProductExecutionKind, } from "./generated/host-callbacks.js"; // The typed capability interfaces below come straight from the @@ -58,6 +59,8 @@ export type LogLevel = string; export interface ProductRuntimeConfig { /** Stable identifier used to scope product accounts, permissions, and storage. */ productId: string; + /** Trusted executable kind selected by the host; defaults to `App`. */ + executionKind?: ProductExecutionKind; /** Metadata describing the host application. */ host: { /** Human-readable host name. */ diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 8ea3eb86..4aa1dd04 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -4,6 +4,7 @@ import type { LogLevel, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + ProductExecutionKind, RequiredHostCallbacks, TrUApiProductProvider, } from "../index.js"; @@ -21,11 +22,15 @@ import { bytesToHex } from "@parity/truapi/scale"; import { startRawSubscription } from "../generated/worker-callbacks.js"; import { errorMessage } from "../error.js"; -export type WebWorkerHostConfig = Omit; +export type WebWorkerHostConfig = Omit< + ProductRuntimeConfig, + "productId" | "executionKind" +>; export interface WorkerPairingHostRuntime { createProvider(product: { productId: string; + executionKind?: ProductExecutionKind; }): Promise; disconnectSession(): Promise; cancelPairing(): void; diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 3c75785d..e58dc4f0 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -104,7 +104,11 @@ function runtimeConfig( function hostConfigFromRuntimeConfig( config: ProductRuntimeConfig, ): CreateWebWorkerPairingHostRuntimeOptions["hostConfig"] { - const { productId: _productId, ...hostConfig } = config; + const { + productId: _productId, + executionKind: _executionKind, + ...hostConfig + } = config; return hostConfig; } @@ -146,7 +150,11 @@ async function createProviderFromRuntime( ...runtimeOptions, hostConfig: hostConfigFromRuntimeConfig(cfg), }); - const provider = await runtime.createProvider({ productId: cfg.productId }); + const provider = await runtime.createProvider( + cfg.executionKind === undefined + ? { productId: cfg.productId } + : { productId: cfg.productId, executionKind: cfg.executionKind }, + ); return { ...provider, dispose(): void { @@ -273,6 +281,29 @@ describe("createWebWorkerPairingHostRuntime", () => { expect(worker.messages.at(-1)).toEqual({ kind: "dispose" }); }); + it("binds a host-selected execution kind to the product core", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig({ executionKind: "Chat" }); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { runtimeConfig: config }, + ); + + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + await settle(); + + const createCore = lastMessageOfKind(worker, "createCore"); + expect(createCore).toEqual({ + kind: "createCore", + coreId: 1, + product: { productId: "dotli.dot", executionKind: "Chat" }, + }); + worker.emit({ kind: "coreReady", coreId: 1 }); + (await providerPromise).dispose(); + }); + it("dev global setLogLevel updates every live worker provider", async () => { const previous = devGlobal.__truapi; delete devGlobal.__truapi; diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 300a0c54..95063470 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -96,7 +96,10 @@ describe("generated client transport", () => { const client = createClient(transport); const request = { - productAccountId: { dotNsIdentifier: "foo", derivationIndex: { tag: "Left", value: 0 } }, + productAccountId: { + dotNsIdentifier: "foo", + derivationIndex: { tag: "Left", value: 0 }, + }, }; void client.account.getAccount(request); @@ -156,7 +159,10 @@ describe("generated client transport", () => { const client = createClient(transport); const response = client.account.getAccount({ - productAccountId: { dotNsIdentifier: "foo", derivationIndex: { tag: "Left", value: 0 } }, + productAccountId: { + dotNsIdentifier: "foo", + derivationIndex: { tag: "Left", value: 0 }, + }, }); const reason = { tag: "V1", value: { tag: "NotConnected", value: undefined } } as const; const frame = unwrap( @@ -237,6 +243,104 @@ describe("generated client transport", () => { expect(events).toEqual(["Connected"]); }); + it("uses one subscription id for both renderer stream directions", () => { + const fixture = providerFixture(); + const transport = createTransport(fixture.provider); + const client = createClient(transport); + const renders: T.ProductChatCustomMessageRenderSubscribeItem[] = []; + const renderer = client.chat.customMessageRenderSubscribe(); + + expect(() => + renderer.requests.next({ tag: "Failed", value: { messageId: "too-early" } }), + ).toThrow("subscribe to responses before sending paired-stream requests"); + + const subscription = renderer.responses.subscribe({ next: (item) => renders.push(item) }); + const start = unwrap( + encodeWireMessage({ + requestId: subscription.subscriptionId, + payload: { + id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.start, + value: indexedTaggedUnion({ V1: [0, _void] }).enc({ + tag: "V1", + value: undefined, + }), + }, + }), + "encode renderer start", + ); + expect(toHex(fixture.sent[0])).toBe(toHex(start)); + + const item = { + messageId: "message-1", + messageType: "vote", + payload: "0x0102", + } as const; + fixture.receive( + unwrap( + encodeWireMessage({ + requestId: subscription.subscriptionId, + payload: { + id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.receive, + value: T.VersionedProductChatCustomMessageRenderSubscribeItem.enc({ + tag: "V1", + value: item, + }), + }, + }), + "encode renderer work", + ), + ); + expect(renders).toEqual([item]); + + const update = { + tag: "Update", + value: { + messageId: item.messageId, + node: { tag: "String", value: { text: "Votes: 1" } }, + }, + } as const; + renderer.requests.next(update); + const request = unwrap( + encodeWireMessage({ + requestId: subscription.subscriptionId, + payload: { + id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.receive, + value: T.VersionedProductChatCustomMessageRenderSubscribeRequest.enc({ + tag: "V1", + value: update, + }), + }, + }), + "encode renderer update", + ); + expect(toHex(fixture.sent[1])).toBe(toHex(request)); + }); + + it("allows a renderer stream to reopen after the host completes it", () => { + const fixture = providerFixture(); + const transport = createTransport(fixture.provider); + const renderer = createClient(transport).chat.customMessageRenderSubscribe(); + const first = renderer.responses.subscribe(); + + fixture.receive( + unwrap( + encodeWireMessage({ + requestId: first.subscriptionId, + payload: { + id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.interrupt, + value: _void.enc(undefined), + }, + }), + "encode renderer completion", + ), + ); + + expect(() => + renderer.requests.next({ tag: "Failed", value: { messageId: "closed" } }), + ).toThrow("subscribe to responses before sending paired-stream requests"); + expect(() => renderer.responses.subscribe()).not.toThrow(); + }); + it("completes the observable on a payloadless interrupt terminator", () => { const fixture = providerFixture(); const transport = createTransport(fixture.provider); diff --git a/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index f45481f4..ea24d9b4 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -6,6 +6,7 @@ import { type ProtocolMessage, type RequestFrameIds, type RequestParams, + type SendSubscriptionItemParams, type SubscriptionFrameIds, type SubscribeRawParams, type Subscription, @@ -411,6 +412,26 @@ export function createTransport( }, }; }, + /** + * Send one value on the product-to-host half of a paired subscription. + */ + sendSubscriptionItem({ + ids, + subscriptionId, + payload, + }: SendSubscriptionItemParams) { + const subscription = subscriptions.get(subscriptionId); + if (!subscription || subscription.ids.receive !== ids.receive) { + throw new Error("paired subscription is not active"); + } + send({ + requestId: subscriptionId, + payload: { + id: ids.receive, + value: payload, + }, + }); + }, /** * Close this transport and detach its provider listeners. */ diff --git a/js/packages/truapi/src/transport.ts b/js/packages/truapi/src/transport.ts index 926ec77b..2e61744c 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -105,6 +105,16 @@ export interface ObservableLike { [Symbol.observable](): ObservableLike; } +/** + * Minimal write-only stream used by generated paired-stream APIs. + **/ +export interface SubjectLike { + /** + * Send one item on the active paired stream. + **/ + next(value: Item): void; +} + /** * Numeric frame ids for a one-shot request method. **/ @@ -196,6 +206,18 @@ export interface SubscribeRawParams { onClose?: (error: Error) => void; } +/** + * Options accepted when sending a value on an active subscription. + **/ +export interface SendSubscriptionItemParams { + /** Wire discriminants for the subscription method. **/ + ids: SubscriptionFrameIds; + /** Transport-assigned id returned by `subscribeRaw`. **/ + subscriptionId: string; + /** SCALE-encoded stream item. **/ + payload: Uint8Array; +} + /** * Byte-level transport used by generated client stubs. **/ @@ -219,6 +241,11 @@ export interface TrUApiTransport { **/ subscribeRaw(params: SubscribeRawParams): Subscription; + /** + * Send one `_receive` value on an active paired subscription. + **/ + sendSubscriptionItem(params: SendSubscriptionItemParams): void; + /** * Tear down the transport and release the listeners it registered on the * underlying `WireProvider`. Pending requests reject and live subscriptions diff --git a/package-lock.json b/package-lock.json index 9b8730ba..2a461fc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -438,9 +438,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 003a6e86..b94755d4 100644 --- a/package.json +++ b/package.json @@ -23,5 +23,6 @@ "devDependencies": { "@changesets/cli": "^2.28.1", "prettier": "^3.8.3" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/playground/README.md b/playground/README.md index 5365f21c..4cf9ea38 100644 --- a/playground/README.md +++ b/playground/README.md @@ -14,6 +14,11 @@ The playground is an interactive reference for the TrUAPI: every method grouped - **Auto-test view**: runs every method and reports pass / fail in one pass. - **Diagnosis view**: runs the full surface and produces a copy-pasteable markdown report per host. The explorer's Compatibility page aggregates those into a cross-host matrix. See [Diagnosis](#diagnosis). - **Wiring status**: methods that are not yet bound are flagged "Not supported" so you can see protocol coverage at a glance. +- **Chat worker**: the same build emits `out/worker/index.js`, which exercises + every generated Chat method. It verifies room creation, the intentionally + unavailable bot registration call, room-list updates, text and custom + messages, actions, and the paired custom-renderer streams before answering + `!echo `. ## Local development @@ -30,6 +35,10 @@ https://dot.li/localhost:3000 The app needs a host to connect to. Opening it directly in a regular browser will not work. +`yarn build` produces both the static SPA under `out/` and its Chat executable +at `out/worker/index.js`. Both resolve `@parity/truapi` from the linked +workspace package in `../js/packages/truapi`. + ## Adding a method Methods reach the playground via codegen — there is no per-method wiring file to edit. The flow: diff --git a/playground/package.json b/playground/package.json index 92c622de..876887a2 100644 --- a/playground/package.json +++ b/playground/package.json @@ -8,9 +8,9 @@ "dev": "next dev", "start": "npx serve out", "prebuild": "node ./scripts/bundle-rxjs-dts.mjs", - "build": "next build", + "build": "next build && vite build --config vite.config.worker.ts", "prelint": "node ./scripts/bundle-rxjs-dts.mjs", - "lint": "eslint src test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", + "lint": "eslint src worker vite.config.worker.ts test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", "pretypecheck": "node ./scripts/bundle-rxjs-dts.mjs", "typecheck": "tsc --noEmit", "typecheck:examples": "tsc -p tsconfig.examples.json", @@ -42,7 +42,8 @@ "eslint": "^9", "eslint-config-next": "^15.5.4", "jsqr": "^1.4.0", - "typescript": "^6.0" + "typescript": "^6.0", + "vite": "^8.0.16" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/playground/vite.config.worker.ts b/playground/vite.config.worker.ts new file mode 100644 index 00000000..6ed0cd39 --- /dev/null +++ b/playground/vite.config.worker.ts @@ -0,0 +1,15 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: "worker", + build: { + outDir: resolve("out/worker"), + emptyOutDir: true, + lib: { + entry: "index.ts", + formats: ["es"], + fileName: "index", + }, + }, +}); diff --git a/playground/worker/index.ts b/playground/worker/index.ts new file mode 100644 index 00000000..18ec9bf8 --- /dev/null +++ b/playground/worker/index.ts @@ -0,0 +1,182 @@ +import { getClientSync } from "@parity/truapi/sandbox"; +import type { + HostChatActionSubscribeItem, + HostChatListSubscribeItem, + ObservableLike, + ProductChatCustomMessageRenderSubscribeItem, +} from "@parity/truapi"; + +const ROOM_ID = "truapi-playground"; +const ROOM_NAME = "TrUAPI Playground"; +const BOT_ID = "truapi-playground-bot"; +const ECHO_COMMAND = "!echo"; +const RENDER_MESSAGE_TYPE = "truapi-chat-api-check"; +const RENDER_PAYLOAD = + "0x637573746f6d5f6d6573736167655f72656e6465725f737562736372696265"; + +const client = getClientSync(); +if (!client) { + throw new Error("TrUAPI Playground Chat worker requires a host connection"); +} +const chat = client.chat; + +const room = await chat.createRoom({ + roomId: ROOM_ID, + name: ROOM_NAME, + icon: "", +}); +if (room.isErr()) { + throw new Error( + `Unable to create the Playground Chat room: ${JSON.stringify(room.error)}`, + ); +} + +const bot = await chat.registerBot({ + botId: BOT_ID, + name: ROOM_NAME, + icon: "", +}); +if ( + bot.isOk() || + bot.error.tag !== "HostFailure" || + bot.error.value.reason !== "unavailable" +) { + throw new Error( + `registerBot no longer has the expected unavailable behavior: ${JSON.stringify(bot)}`, + ); +} + +const renderer = chat.customMessageRenderSubscribe(); +renderer.responses.subscribe({ + next(item) { + handleRenderRequest(item); + }, + error(error) { + console.error("TrUAPI Playground custom renderer stream failed", error); + }, +}); + +const rooms = chat.listSubscribe(); +await waitForPlaygroundRoom(rooms); + +await postMessage({ + tag: "Text", + value: { + text: 'Chat API checks passed. Send "!echo " to test actions.', + }, +}); +await postMessage({ + tag: "Custom", + value: { + messageType: RENDER_MESSAGE_TYPE, + payload: RENDER_PAYLOAD, + }, +}); + +chat.actionSubscribe().subscribe({ + next(action) { + void handleAction(action).catch((error: unknown) => { + console.error("Unable to handle the Playground Chat action", error); + }); + }, + error(error) { + console.error("TrUAPI Playground Chat action stream failed", error); + }, +}); + +function handleRenderRequest( + item: ProductChatCustomMessageRenderSubscribeItem, +): void { + if (item.messageType !== RENDER_MESSAGE_TYPE) { + renderer.requests.next({ + tag: "Failed", + value: { messageId: item.messageId }, + }); + return; + } + + renderer.requests.next({ + tag: "Update", + value: { + messageId: item.messageId, + node: { + tag: "Text", + value: { + modifiers: [], + props: {}, + children: [ + { + tag: "String", + value: { + text: `${decodeHex(item.payload)}: passed`, + }, + }, + ], + }, + }, + }, + }); +} + +async function waitForPlaygroundRoom( + observable: ObservableLike, +): Promise { + await new Promise((resolve, reject) => { + const subscription = observable.subscribe({ + next(item) { + if (item.rooms.some((candidate) => candidate.roomId === ROOM_ID)) { + subscription.unsubscribe(); + resolve(); + } + }, + error: reject, + complete() { + reject(new Error("Chat room list ended before returning the room")); + }, + }); + }); +} + +async function postMessage( + payload: Parameters[0]["payload"], +): Promise { + const result = await chat.postMessage({ roomId: ROOM_ID, payload }); + if (result.isErr()) { + throw new Error( + `Unable to post a Playground Chat message: ${JSON.stringify(result.error)}`, + ); + } + return result.value.messageId; +} + +async function handleAction( + action: HostChatActionSubscribeItem, +): Promise { + if (action.payload.tag !== "MessagePosted") return; + if (action.payload.value.tag !== "Text") return; + + const text = action.payload.value.value.text.trim(); + if (!text.startsWith(ECHO_COMMAND)) return; + + const body = text.slice(ECHO_COMMAND.length).trim(); + const result = await chat.postMessage({ + roomId: action.roomId, + payload: { + tag: "Text", + value: { + text: body ? `Echo: ${body}` : `Usage: ${ECHO_COMMAND} `, + }, + }, + }); + if (result.isErr()) { + console.error("Unable to post the Playground Chat reply", result.error); + } +} + +function decodeHex(value: `0x${string}`): string { + const bytes = value + .slice(2) + .match(/.{1,2}/g) + ?.map((byte) => Number.parseInt(byte, 16)); + return new TextDecoder().decode(Uint8Array.from(bytes ?? [])); +} diff --git a/playground/yarn.lock b/playground/yarn.lock index ad5a4d73..5b63f35f 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@emnapi/core@2.0.0-alpha.3": + version "2.0.0-alpha.3" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-2.0.0-alpha.3.tgz#049ace671f30274d6a4d161d3b771138b862f704" + integrity sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g== + dependencies: + "@emnapi/wasi-threads" "2.0.1" + tslib "^2.4.0" + "@emnapi/core@^1.4.3": version "1.10.0" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467" @@ -10,6 +18,13 @@ "@emnapi/wasi-threads" "1.2.1" tslib "^2.4.0" +"@emnapi/runtime@2.0.0-alpha.3": + version "2.0.0-alpha.3" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz#aef04c35c9a83c23ab0f251f03095440edd7ad5f" + integrity sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": version "1.10.0" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" @@ -24,6 +39,13 @@ dependencies: tslib "^2.4.0" +"@emnapi/wasi-threads@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz#1c92919328be1f6ab79fb60a67ccf3d2e62cd48b" + integrity sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ== + dependencies: + tslib "^2.4.0" + "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz" @@ -319,6 +341,13 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@napi-rs/wasm-runtime@^1.2.0": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz#c70706532e5827c0932ca6bf43ee2c512f29c639" + integrity sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@next/env@15.5.21": version "15.5.21" resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.21.tgz#782aa4d6b08ddfd12ac7e17ca85b7ee1b61c500b" @@ -402,12 +431,14 @@ resolved "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz" integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== +"@oxc-project/types@=0.142.0": + version "0.142.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.142.0.tgz#0b4bd7841ef3dd267a69184b97452cc0b313fb52" + integrity sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ== + "@parity/truapi@link:../js/packages/truapi": - version "0.5.1" - dependencies: - "@noble/hashes" "^2.2.0" - neverthrow "^8.2.0" - scale-ts "^1.6.1" + version "0.0.0" + uid "" "@playwright/test@^1.49.1": version "1.59.1" @@ -439,6 +470,90 @@ resolved "https://registry.yarnpkg.com/@polkadot-api/utils/-/utils-0.4.0.tgz#6ee6476aa40dbdb92e4ded39d2feb9002b5b509a" integrity sha512-9b/hwRM0UloLWV7SfpNaSD/4k8UQAHoaACAk7Xe+1MlfAm2JtnmPiB1GfGrfTyBlsrJVUIBCZpEmbmxVMaIqBA== +"@rolldown/binding-android-arm64@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz#dbc5a2453c063aa9b2974410d19b4be2e730856f" + integrity sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA== + +"@rolldown/binding-darwin-arm64@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz#dd43dc12d5acd6a34edbd48cdf3008f5d4c52e2a" + integrity sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA== + +"@rolldown/binding-darwin-x64@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz#00b32d5e0adba8aab6925633280ed1bf6d90c1d2" + integrity sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ== + +"@rolldown/binding-freebsd-x64@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz#0884face257d2763024754abae947e1c0b7b6c24" + integrity sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg== + +"@rolldown/binding-linux-arm-gnueabihf@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz#a82580ac5cf3030c65ccf6181326d64f91889524" + integrity sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA== + +"@rolldown/binding-linux-arm64-gnu@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz#5221b6c7eab55f82d776e65f9146d387cec3791e" + integrity sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg== + +"@rolldown/binding-linux-arm64-musl@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz#3552c35275daad1f1553f1f29452aa62bffbd120" + integrity sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A== + +"@rolldown/binding-linux-ppc64-gnu@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz#6b4972d32614b70885c5bf580695fb1e365b5313" + integrity sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg== + +"@rolldown/binding-linux-s390x-gnu@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz#42ebada72e617003e30b67ac9b4afff66a75b15a" + integrity sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ== + +"@rolldown/binding-linux-x64-gnu@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz#37734bbf4b90cf39da5301f18b6f089b65a745d9" + integrity sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw== + +"@rolldown/binding-linux-x64-musl@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz#95170913f28700fbd56c8d85649e3960abe5632f" + integrity sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A== + +"@rolldown/binding-openharmony-arm64@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz#d163a5fe25d3542b7b325c05e0211825f42d2b5c" + integrity sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw== + +"@rolldown/binding-wasm32-wasi@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz#8333205b61997d298fb433214a99b01874459342" + integrity sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww== + dependencies: + "@emnapi/core" "2.0.0-alpha.3" + "@emnapi/runtime" "2.0.0-alpha.3" + "@napi-rs/wasm-runtime" "^1.2.0" + +"@rolldown/binding-win32-arm64-msvc@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz#c730559e7d74fa9ba24106d9104e7c8dc25f2236" + integrity sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw== + +"@rolldown/binding-win32-x64-msvc@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz#ea040d8719fe26c8b0d781081329b20a1606a006" + integrity sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ== + +"@rolldown/pluginutils@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" + integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== + "@rollup/rollup-linux-x64-gnu@^4.24.0": version "4.60.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz#23c9bf79771d804fb87415eb0767569f273261e5" @@ -473,6 +588,13 @@ dependencies: tslib "^2.4.0" +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" @@ -1046,7 +1168,7 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" -detect-libc@^2.1.2: +detect-libc@^2.0.3, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -1503,6 +1625,11 @@ fsevents@2.3.2: resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -1965,6 +2092,80 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -2045,6 +2246,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nanoid@^3.3.16: + version "3.3.16" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" + integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + nanoid@^3.3.6: version "3.3.12" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz" @@ -2221,7 +2427,7 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -picocolors@^1.0.0: +picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -2236,6 +2442,11 @@ picomatch@^4.0.4: resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz" integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== +picomatch@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + pirates@^4.0.1: version "4.0.7" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" @@ -2269,6 +2480,15 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.5.23: + version "8.5.25" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.25.tgz#5012a598eaaa897f21bbe8553be3cb7bd2bd78cb" + integrity sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw== + dependencies: + nanoid "^3.3.16" + picocolors "^1.1.1" + source-map-js "^1.2.1" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -2363,6 +2583,30 @@ reusify@^1.0.4: resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== +rolldown@~1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.1.tgz#644204bde3da11e0eaa8d74994be25b90fcf4d44" + integrity sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw== + dependencies: + "@oxc-project/types" "=0.142.0" + "@rolldown/pluginutils" "^1.0.0" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.2.1" + "@rolldown/binding-darwin-arm64" "1.2.1" + "@rolldown/binding-darwin-x64" "1.2.1" + "@rolldown/binding-freebsd-x64" "1.2.1" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.1" + "@rolldown/binding-linux-arm64-gnu" "1.2.1" + "@rolldown/binding-linux-arm64-musl" "1.2.1" + "@rolldown/binding-linux-ppc64-gnu" "1.2.1" + "@rolldown/binding-linux-s390x-gnu" "1.2.1" + "@rolldown/binding-linux-x64-gnu" "1.2.1" + "@rolldown/binding-linux-x64-musl" "1.2.1" + "@rolldown/binding-openharmony-arm64" "1.2.1" + "@rolldown/binding-wasm32-wasi" "1.2.1" + "@rolldown/binding-win32-arm64-msvc" "1.2.1" + "@rolldown/binding-win32-x64-msvc" "1.2.1" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" @@ -2542,7 +2786,7 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -source-map-js@^1.0.2: +source-map-js@^1.0.2, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -2697,6 +2941,14 @@ tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.4" +tinyglobby@^0.2.17: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" @@ -2835,6 +3087,19 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +vite@^8.0.16: + version "8.2.0" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.0.tgz#902fcd3dc0312f553c85b6cbc4625dcaca2d8df8" + integrity sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ== + dependencies: + lightningcss "^1.33.0" + picomatch "^4.0.5" + postcss "^8.5.23" + rolldown "~1.2.0" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz" diff --git a/rust/crates/truapi-codegen/src/rust/dispatcher.rs b/rust/crates/truapi-codegen/src/rust/dispatcher.rs index 183327ed..d082276e 100644 --- a/rust/crates/truapi-codegen/src/rust/dispatcher.rs +++ b/rust/crates/truapi-codegen/src/rust/dispatcher.rs @@ -107,7 +107,13 @@ fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result, + request_stream_wrapper: Option, response_wrapper: Option, error_payload: WirePayload, item_wrapper: Option, + required_execution: Option, } #[derive(Clone)] @@ -165,11 +173,13 @@ impl MethodEmission { module: &str, wire_method: &str, method: &MethodDef, + required_execution: Option<&str>, ) -> Result { let versioned_wrappers = versioned_wrapper_names(api); - let request_payload = match method.params.as_slice() { - [] => None, - [param] => match ¶m.type_ref { + let request_payload = match (method.kind, method.params.as_slice()) { + (MethodKind::StreamPair, _) => None, + (_, []) => None, + (_, [param]) => match ¶m.type_ref { TypeRef::Named { name, args } if args.is_empty() && versioned_wrappers.contains(name) => { @@ -177,12 +187,35 @@ impl MethodEmission { } _ => Some(WirePayload::Raw(param.type_ref.clone())), }, - _ => bail!( + (_, _) => bail!( "Method `{}`: expected at most one request parameter (got {})", method.name, method.params.len() ), }; + let request_stream_wrapper = if matches!(method.kind, MethodKind::StreamPair) { + let request = method + .params + .first() + .and_then(|param| subscription_item_type(¶m.type_ref)) + .ok_or_else(|| { + anyhow::anyhow!( + "Method `{}`: stream pair requires a Subscription parameter", + method.name + ) + })?; + Some( + versioned_wrapper_root( + &method.name, + "request stream item", + request, + &versioned_wrappers, + )? + .to_string(), + ) + } else { + None + }; let error_payload = match &method.return_type { ReturnType::Result { err, .. } | ReturnType::ResultSubscription { err, .. } => { @@ -237,9 +270,11 @@ impl MethodEmission { module: module.to_string(), kind: method.kind, request_payload, + request_stream_wrapper, response_wrapper, error_payload, item_wrapper, + required_execution: required_execution.map(str::to_string), }) } @@ -249,6 +284,7 @@ impl MethodEmission { MethodKind::Subscription | MethodKind::ResultSubscription => { self.write_subscription(out, host_expr) } + MethodKind::StreamPair => self.write_stream_pair(out, host_expr), } } @@ -267,16 +303,17 @@ impl MethodEmission { let method = &self.name; let ids = const_name(&self.wire_name); + writeln!(out, " {{").unwrap(); + self.write_execution_binding(out); write_indented( out, - 4, + 8, &formatdoc! { r#" - {{ - let host = {host_expr}; - dispatcher.on_request(wire_table::{ids}, move |request_id: String, bytes: Vec| {{ - let host = host.clone(); - Box::pin(async move {{ + let host = {host_expr}; + dispatcher.on_request(wire_table::{ids}, move |request_id: String, bytes: Vec| {{ + let host = host.clone(); + Box::pin(async move {{ "# }, ); @@ -352,6 +389,7 @@ impl MethodEmission { " let cx = CallContext::with_request_id(request_id.clone());" ) .unwrap(); + self.write_request_execution_check(out, target_version_expr.as_deref())?; match &self.response_wrapper { Some(response) => { let Some(target_version_expr) = target_version_expr.as_deref() else { @@ -434,16 +472,17 @@ impl MethodEmission { let is_result_sub = matches!(self.kind, MethodKind::ResultSubscription); + writeln!(out, " {{").unwrap(); + self.write_execution_binding(out); write_indented( out, - 4, + 8, &formatdoc! { r#" - {{ - let host = {host_expr}; - dispatcher.on_subscription(wire_table::{ids}, move |request_id: String, bytes: Vec| {{ - let host = host.clone(); - Box::pin(async move {{ + let host = {host_expr}; + dispatcher.on_subscription(wire_table::{ids}, move |request_id: String, bytes: Vec| {{ + let host = host.clone(); + Box::pin(async move {{ "# }, ); @@ -506,6 +545,28 @@ impl MethodEmission { " let cx = CallContext::with_request_id(request_id.clone());" ) .unwrap(); + if self.required_execution.is_some() && is_result_sub { + let error = error.expect("result subscription error checked above"); + write_indented( + out, + 16, + &formatdoc! { + r#" + if !execution_allowed {{ + let error: truapi::CallError = + truapi::CallError::Denied; + return Err(encode_versioned_interrupt_payload(error, {target_version_expr})); + }} + "# + }, + ); + } else if self.required_execution.is_some() { + writeln!( + out, + " if !execution_allowed {{ return Err(Vec::new()); }}" + ) + .unwrap(); + } if is_result_sub { if error.is_none() { bail!("Method `{method}`: result subscription methods must have an error wrapper"); @@ -549,6 +610,115 @@ impl MethodEmission { ); Ok(()) } + + fn write_stream_pair(&self, out: &mut String, host_expr: &str) -> Result<()> { + let module = &self.module; + let method = &self.name; + let ids = const_name(&self.wire_name); + let request = self.request_stream_wrapper.as_deref().ok_or_else(|| { + anyhow::anyhow!("Method `{method}`: stream pair has no request wrapper") + })?; + let item = self.item_wrapper.as_deref().ok_or_else(|| { + anyhow::anyhow!("Method `{method}`: stream pair has no response wrapper") + })?; + writeln!(out, " {{").unwrap(); + self.write_execution_binding(out); + write_indented( + out, + 8, + &formatdoc! { + r#" + let host = {host_expr}; + dispatcher.on_stream_pair(wire_table::{ids}, move |request_id: String, bytes: Vec, requests| {{ + let host = host.clone(); + Box::pin(async move {{ + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + "# + }, + ); + if self.required_execution.is_some() { + writeln!( + out, + " if !execution_allowed {{ return Err(Vec::new()); }}" + ) + .unwrap(); + } + writeln!( + out, + " let requests = subscription_request_stream::(requests);" + ) + .unwrap(); + writeln!( + out, + " let stream = host.{method}(&cx, requests).await;" + ) + .unwrap(); + writeln!( + out, + " Ok(subscription_stream::(stream))" + ) + .unwrap(); + writeln!(out, " }})").unwrap(); + writeln!(out, " }});").unwrap(); + writeln!(out, " }}").unwrap(); + Ok(()) + } + + fn write_execution_binding(&self, out: &mut String) { + if let Some(required) = self.required_execution.as_ref() { + writeln!( + out, + " let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::{required});" + ) + .unwrap(); + } + } + + fn write_request_execution_check( + &self, + out: &mut String, + target_version_expr: Option<&str>, + ) -> Result<()> { + if self.required_execution.is_none() { + return Ok(()); + } + let module = &self.module; + match (&self.error_payload, target_version_expr) { + (WirePayload::Versioned(error), Some(target)) => write_indented( + out, + 16, + &formatdoc! { + r#" + if !execution_allowed {{ + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, {target})); + }} + "# + }, + ), + (WirePayload::Raw(error), _) => { + let error = rust_type_ref(error)?; + write_indented( + out, + 16, + &formatdoc! { + r#" + if !execution_allowed {{ + let error: truapi::CallError<{error}> = truapi::CallError::Denied; + return Ok(encode_raw_err_payload(error)); + }} + "# + }, + ); + } + (WirePayload::Versioned(_), None) => { + bail!("execution-filtered request has no target wire version") + } + } + Ok(()) + } } impl WirePayload { @@ -734,6 +904,7 @@ fn write_imports( r#" }}; use truapi::versioned::{{self, Versioned}}; + use truapi_platform::ProductExecutionKind; use crate::dispatcher::Dispatcher; use crate::frame::encode_versioned_err_payload; @@ -741,7 +912,7 @@ fn write_imports( use crate::frame::encode_versioned_ok_payload; use crate::frame::encode_versioned_unit_ok_payload; use crate::generated::wire_table; - use crate::subscription::subscription_stream; + use crate::subscription::{{subscription_request_stream, subscription_stream}}; "# ) .unwrap(); diff --git a/rust/crates/truapi-codegen/src/rust/wire_table.rs b/rust/crates/truapi-codegen/src/rust/wire_table.rs index 8696b575..b64e535b 100644 --- a/rust/crates/truapi-codegen/src/rust/wire_table.rs +++ b/rust/crates/truapi-codegen/src/rust/wire_table.rs @@ -99,7 +99,7 @@ fn method_entry(trait_def: &TraitDef, method: &MethodDef) -> Result response_id, })) } - MethodKind::Subscription | MethodKind::ResultSubscription => { + MethodKind::Subscription | MethodKind::ResultSubscription | MethodKind::StreamPair => { if wire.request_id.is_some() || wire.response_id.is_some() { bail!( "method `{}::{}` is a subscription and must not use request wire ids", diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 933e23ef..527eb9cd 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -70,10 +70,23 @@ pub struct TraitDef { pub module_path: Vec, /// Methods declared on the trait, in declaration order. pub methods: Vec, - /// Rustdoc comment on the trait, with hidden codegen markers stripped. + /// Rustdoc comment on the trait. Service markers are retained for codegen. pub docs: Option, } +impl TraitDef { + /// Required trusted execution kind declared by `#[truapi::service]`. + pub fn required_execution(&self) -> Option<&str> { + let docs = self.docs.as_deref()?; + extract_marker_value(docs, "@service_required_execution=") + } + + /// User-facing trait documentation with codegen markers removed. + pub fn public_docs(&self) -> Option { + clean_docs(self.docs.as_deref()) + } +} + /// Trait method extracted from rustdoc, including its wire ids. #[derive(Debug, PartialEq, Eq)] pub struct MethodDef { @@ -117,6 +130,8 @@ pub enum MethodKind { Subscription, /// One request, a stream of `Result` items. ResultSubscription, + /// A product-to-host request stream paired with a host-to-product item stream. + StreamPair, } /// Trait method parameter (name + type). @@ -630,7 +645,7 @@ fn extract_trait( name, module_path, methods, - docs: clean_docs(item.docs.as_deref()), + docs: item.docs.clone(), }) } @@ -653,7 +668,7 @@ fn extract_method(item_id: &str, item: &Item, names: &NameContext) -> Result Result) -> Option { fn is_codegen_doc_marker(line: &str) -> bool { let line = line.trim_start(); - line.starts_with("@wire_") + line.starts_with("@wire_") || line.starts_with("@service_") +} + +fn extract_marker_value<'a>(docs: &'a str, marker: &str) -> Option<&'a str> { + docs.lines().find_map(|line| { + line.trim_start() + .strip_prefix(marker) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) +} + +/// Return the item type carried by a `Subscription` parameter. +pub fn subscription_item_type(ty: &TypeRef) -> Option<&TypeRef> { + match ty { + TypeRef::Named { name, args } if name == "Subscription" && args.len() == 1 => args.first(), + _ => None, + } } /// Extracts `@wire__id=N` markers from a doc comment block. Annotated @@ -1450,11 +1489,24 @@ mod tests { #[test] fn clean_docs_strips_wire_markers() { - let docs = "Trait summary.\n\n@wire_request_id=7\n"; + let docs = "Trait summary.\n\n@wire_request_id=7\n@service_required_execution=Chat\n"; assert_eq!(clean_docs(Some(docs)).as_deref(), Some("Trait summary.")); } + #[test] + fn trait_exposes_required_execution_without_leaking_marker() { + let trait_def = TraitDef { + name: "Chat".into(), + module_path: Vec::new(), + methods: Vec::new(), + docs: Some("Chat operations.\n\n@service_required_execution=Chat".into()), + }; + + assert_eq!(trait_def.required_execution(), Some("Chat")); + assert_eq!(trait_def.public_docs().as_deref(), Some("Chat operations.")); + } + #[test] fn parse_accepts_tested_format_version() { let json = format!(r#"{{ "format_version": {MIN_FORMAT_VERSION}, "index": {{}} }}"#); diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 019c33f7..01629e5b 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -7,7 +7,7 @@ use std::path::Path; use anyhow::{Result, bail}; use convert_case::{Case, Casing}; -use indoc::{formatdoc, writedoc}; +use indoc::{formatdoc, indoc, writedoc}; use crate::rustdoc::*; @@ -696,7 +696,7 @@ fn wire_ids_for_method(trait_def: &TraitDef, method: &MethodDef) -> Result { + MethodKind::Subscription | MethodKind::ResultSubscription | MethodKind::StreamPair => { if wire.request_id.is_some() || wire.response_id.is_some() { bail!( "method `{}::{}` is a subscription and must not use request wire ids", @@ -936,12 +936,12 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) import * as S from '../scale.js'; import type {{ HexString }} from '../scale.js'; import {{ SubscriptionError }} from '../transport.js'; - import type {{ ObservableLike, Observer, Subscription, SubscriptionFrameIds, TrUApiTransport }} from '../transport.js'; + import type {{ ObservableLike, Observer, SubjectLike, Subscription, SubscriptionFrameIds, TrUApiTransport }} from '../transport.js'; import * as T from './types.js'; import * as W from './wire-table.js'; export {{ ResultAsync, SubscriptionError }}; - export type {{ ObservableLike, Observer, Result, Subscription, TrUApiTransport }}; + export type {{ ObservableLike, Observer, Result, SubjectLike, Subscription, TrUApiTransport }}; export const TRUAPI_VERSION = {target_version} as const; export const TRUAPI_CODEC_VERSION = {codec_version} as const; @@ -955,6 +955,7 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) ) .unwrap(); write_observable_helper(&mut out); + write_stream_pair_helper(&mut out); let ctx = codec_context(&[]); let wrappers = collect_versioned_wrappers(api); @@ -967,7 +968,8 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) continue; } - write_jsdoc(&mut out, "", trait_def.docs.as_deref()); + let public_docs = trait_def.public_docs(); + write_jsdoc(&mut out, "", public_docs.as_deref()); writedoc!( out, " @@ -1142,6 +1144,79 @@ fn write_observable_helper(out: &mut String) { .unwrap(); } +fn write_stream_pair_helper(out: &mut String) { + out.push_str(indoc! { + r#" + function createStreamPair({ + transport, + ids, + payload, + encodeRequest, + decodeItem, + }: { + transport: TrUApiTransport; + ids: SubscriptionFrameIds; + payload: Uint8Array; + encodeRequest: (request: Request) => Uint8Array; + decodeItem: (payload: Uint8Array) => Item; + }): { requests: SubjectLike; responses: ObservableLike } { + let active: Subscription | undefined; + const source = createObservable({ transport, ids, payload, decodeItem }); + const responses: ObservableLike = { + subscribe(observer: Partial> = {}): Subscription { + if (active) throw new Error("paired stream already has an active subscriber"); + let terminated = false; + const subscription = source.subscribe({ + next(value) { + observer.next?.(value); + }, + error(error) { + terminated = true; + active = undefined; + observer.error?.(error); + }, + complete() { + terminated = true; + active = undefined; + observer.complete?.(); + }, + }); + if (!terminated) active = subscription; + return { + get subscriptionId() { + return subscription.subscriptionId; + }, + unsubscribe() { + subscription.unsubscribe(); + if (active === subscription) active = undefined; + }, + }; + }, + [OBSERVABLE_INTEROP as typeof Symbol.observable]() { + return responses; + }, + }; + return { + requests: { + next(request) { + if (!active?.subscriptionId) { + throw new Error("subscribe to responses before sending paired-stream requests"); + } + transport.sendSubscriptionItem({ + ids, + subscriptionId: active.subscriptionId, + payload: encodeRequest(request), + }); + }, + }, + responses, + }; + } + + "# + }); +} + fn included_methods<'a>( trait_def: &'a TraitDef, wrappers: &HashMap, @@ -1414,7 +1489,12 @@ fn emit_method( let ts_method_name = to_camel_case(&strip_prefix(&method.name)); let wire_const = wire_const_name(&trait_def.name, &method.name); let wire_version = method_wire_version(method, wrappers, target_version)?; - let payload = emit_payload(&method.params, wrappers, ctx, wire_version)?; + let payload_params = if matches!(method.kind, MethodKind::StreamPair) { + &[][..] + } else { + method.params.as_slice() + }; + let payload = emit_payload(payload_params, wrappers, ctx, wire_version)?; write_jsdoc(out, " ", method.docs.as_deref()); match (&method.kind, &method.return_type) { @@ -1505,6 +1585,29 @@ fn emit_method( wire_version, )?; } + (MethodKind::StreamPair, ReturnType::Subscription(item)) => { + let request = method + .params + .first() + .and_then(|param| subscription_item_type(¶m.type_ref)) + .ok_or_else(|| { + anyhow::anyhow!( + "stream pair `{}` has no Subscription parameter", + method.name + ) + })?; + let request = emit_response(request, wrappers, ctx, wire_version)?; + let response = emit_response(item, wrappers, ctx, wire_version)?; + emit_stream_pair_method( + out, + &ts_method_name, + &wire_const, + &payload, + &request, + &response, + wire_version, + )?; + } (kind, return_type) => { bail!( "Generator internal mismatch for method `{}`: kind {:?} does not match return type {:?}", @@ -1518,6 +1621,65 @@ fn emit_method( Ok(()) } +fn emit_stream_pair_method( + out: &mut String, + ts_method_name: &str, + wire_const: &str, + payload: &PayloadEmission, + request: &ResponseEmission, + response: &ResponseEmission, + wire_version: Option, +) -> Result<()> { + let request_value = wire_version.map_or_else( + || "request".to_string(), + |version| format!("{{ tag: \"V{version}\", value: request }}"), + ); + let item_value = if let Some(version) = wire_version { + versioned_value_expr( + &format!("{}.dec(payload)", response.wire_codec_expr), + &response.wire_type_ts, + &response.inner_type_ts, + version, + ) + } else { + format!("{}.dec(payload)", response.wire_codec_expr) + }; + writedoc!( + out, + " + {ts_method_name}(): {{ + requests: SubjectLike<{request_type}>; + responses: ObservableLike<{response_type}>; + }} {{ + return createStreamPair<{request_type}, {response_type}>({{ + transport: this.transport, + ids: W.{wire_const}, + ", + request_type = request.inner_type_ts, + response_type = response.inner_type_ts, + ) + .unwrap(); + write_payload_field( + out, + " ", + &payload.wire_codec_expr, + payload.wire_version, + &payload.value_expr, + ); + writedoc!( + out, + " + encodeRequest: (request) => {codec}.enc({request_value}), + decodeItem: (payload) => {item_value}, + }}); + }} + ", + codec = request.wire_codec_expr, + ) + .unwrap(); + Ok(()) +} + /// Emits a subscribe method body that returns an Observable-compatible object. /// Payloadless `_interrupt` maps to `complete`; typed interrupt payloads map /// to `error`. @@ -2468,6 +2630,28 @@ mod tests { } } + fn stream_pair_method_with_wrappers( + name: &str, + wire_id: Option, + request: &str, + item: &str, + ) -> MethodDef { + MethodDef { + name: name.to_string(), + kind: MethodKind::StreamPair, + params: vec![ParamDef { + name: "requests".to_string(), + type_ref: TypeRef::Named { + name: "Subscription".to_string(), + args: vec![named_type(request)], + }, + }], + return_type: ReturnType::Subscription(named_type(item)), + wire: subscription_wire(wire_id), + docs: None, + } + } + fn versioned_tuple_wrapper_variants(name: &str, variants: &[(u32, &str)]) -> TypeDef { TypeDef { name: name.to_string(), @@ -2825,6 +3009,35 @@ mod tests { assert!(!source.contains("futureCall(")); } + #[test] + fn generate_client_emits_subject_and_observable_for_stream_pair() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Chat".to_string(), + module_path: Vec::new(), + methods: vec![stream_pair_method_with_wrappers( + "custom_message_render_subscribe", + Some(52), + "RendererRequest", + "RendererItem", + )], + docs: None, + }], + public_trait_order: vec!["Chat".to_string()], + types: vec![ + versioned_tuple_wrapper_variants("RendererRequest", &[(1, "RendererRequestV1")]), + versioned_tuple_wrapper_variants("RendererItem", &[(1, "RendererItemV1")]), + ], + }; + + let source = generate_client(&api, 1, 1).expect("generate paired-stream client"); + + assert!(source.contains("requests: SubjectLike")); + assert!(source.contains("responses: ObservableLike")); + assert!(source.contains("return createStreamPair")); + assert!(source.contains("transport.sendSubscriptionItem({")); + } + #[test] fn generate_client_selects_highest_shared_wrapper_variant() { let api = ApiDefinition { diff --git a/rust/crates/truapi-codegen/src/ts/playground.rs b/rust/crates/truapi-codegen/src/ts/playground.rs index a53ba28d..3b02c272 100644 --- a/rust/crates/truapi-codegen/src/ts/playground.rs +++ b/rust/crates/truapi-codegen/src/ts/playground.rs @@ -69,7 +69,9 @@ fn generate_playground_services_code( let docs = split_playground_docs(method.docs.as_deref())?; let method_type = match method.kind { MethodKind::Request => "unary", - MethodKind::Subscription | MethodKind::ResultSubscription => "subscription", + MethodKind::Subscription + | MethodKind::ResultSubscription + | MethodKind::StreamPair => "subscription", }; let signature = build_method_signature(method, &payload, &wrappers, &ctx, wire_version)?; diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index b11224ef..da5423f9 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -25,6 +25,7 @@ use truapi::api::{ Theme, }; use truapi::versioned::{self, Versioned}; +use truapi_platform::ProductExecutionKind; use crate::dispatcher::Dispatcher; use crate::frame::encode_versioned_err_payload; @@ -32,7 +33,7 @@ use crate::frame::encode_versioned_interrupt_payload; use crate::frame::encode_versioned_ok_payload; use crate::frame::encode_versioned_unit_ok_payload; use crate::generated::wire_table; -use crate::subscription::subscription_stream; +use crate::subscription::{subscription_request_stream, subscription_stream}; /// Register every TrUAPI method with the dispatcher. pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) @@ -632,6 +633,7 @@ where P: Chat + Send + Sync + 'static, { { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_request(wire_table::CHAT_CREATE_ROOM, move |request_id: String, bytes: Vec| { let host = host.clone(); @@ -649,6 +651,11 @@ where }; let target_version = request.version(); let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } let response: versioned::chat::HostChatCreateRoomResponse = match host.create_room(&cx, request).await { Ok(value) => value, Err(err) => { @@ -660,6 +667,7 @@ where }); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_request(wire_table::CHAT_REGISTER_BOT, move |request_id: String, bytes: Vec| { let host = host.clone(); @@ -677,6 +685,11 @@ where }; let target_version = request.version(); let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } let response: versioned::chat::HostChatRegisterBotResponse = match host.register_bot(&cx, request).await { Ok(value) => value, Err(err) => { @@ -688,18 +701,21 @@ where }); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_subscription(wire_table::CHAT_LIST_SUBSCRIBE, move |request_id: String, bytes: Vec| { let host = host.clone(); Box::pin(async move { let _ = bytes; let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { return Err(Vec::new()); } let stream = host.list_subscribe(&cx).await; Ok(subscription_stream::(stream)) }) }); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_request(wire_table::CHAT_POST_MESSAGE, move |request_id: String, bytes: Vec| { let host = host.clone(); @@ -717,6 +733,11 @@ where }; let target_version = request.version(); let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } let response: versioned::chat::HostChatPostMessageResponse = match host.post_message(&cx, request).await { Ok(value) => value, Err(err) => { @@ -728,28 +749,30 @@ where }); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_subscription(wire_table::CHAT_ACTION_SUBSCRIBE, move |request_id: String, bytes: Vec| { let host = host.clone(); Box::pin(async move { let _ = bytes; let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { return Err(Vec::new()); } let stream = host.action_subscribe(&cx).await; Ok(subscription_stream::(stream)) }) }); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host; - dispatcher.on_subscription(wire_table::CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE, move |request_id: String, bytes: Vec| { + dispatcher.on_stream_pair(wire_table::CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE, move |request_id: String, bytes: Vec, requests| { let host = host.clone(); Box::pin(async move { - let request: versioned::chat::ProductChatCustomMessageRenderSubscribeRequest = match Decode::decode(&mut &bytes[..]) { - Ok(request) => request, - Err(_) => return Err(Vec::new()), - }; + let _ = bytes; let cx = CallContext::with_request_id(request_id.clone()); - let stream = host.custom_message_render_subscribe(&cx, request).await; + if !execution_allowed { return Err(Vec::new()); } + let requests = subscription_request_stream::(requests); + let stream = host.custom_message_render_subscribe(&cx, requests).await; Ok(subscription_stream::(stream)) }) }); diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index a2664097..dca4cc44 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -23,6 +23,11 @@ import { import type { GenericError, + HostChatCreateRoomRequest, + HostChatCreateRoomResponse, + HostChatListSubscribeItem, + HostChatPostMessageRequest, + HostChatPostMessageResponse, HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, @@ -223,6 +228,33 @@ export interface PreimageSubmitReview { size: bigint; } +/** + * Product identity attached to one product-facing TrUAPI connection. + * + * A host may create multiple product runtimes from the same long-lived host + * runtime, each with its own product context. + */ +export interface ProductContext { + /** + * Product identifier used for account derivation and product-scoped + * storage/permission namespaces. + * + * Host-spec C.7 defines accepted product id forms: + * + */ + productId: string; + + /** + * Trusted kind of executable attached to this connection by the host. + */ + executionKind: ProductExecutionKind; +} + +/** + * Trusted kind of product executable attached to a TrUAPI connection. + */ +export type ProductExecutionKind = "App" | "Widget" | "Chat"; + /** * Review shown before allocating resources for a product. Names the * beneficiary product so the user knows which product receives the @@ -490,6 +522,27 @@ export const PreimageSubmitReview: S.Codec = S.lazy( S.Struct({ size: S.u64 }) as S.Codec, ); +/** + * Product identity attached to one product-facing TrUAPI connection. + * + * A host may create multiple product runtimes from the same long-lived host + * runtime, each with its own product context. + */ +export const ProductContext: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ + productId: S.str, + executionKind: ProductExecutionKind, + }) as S.Codec, +); + +/** + * Trusted kind of product executable attached to a TrUAPI connection. + */ +export const ProductExecutionKind: S.Codec = S.lazy( + (): S.Codec => S.Status("App", "Widget", "Chat"), +); + /** * Review shown before allocating resources for a product. Names the * beneficiary product so the user knows which product receives the @@ -603,6 +656,34 @@ export interface ChainProvider { connect(genesisHash: Uint8Array): Promise; } +/** + * Optional native chat storage and UI adapter used by Chat executions. + */ +export interface ChatPlatform { + /** + * Create or resolve a product-scoped native chat room. + */ + createRoom( + product: ProductContext, + request: HostChatCreateRoomRequest, + ): Promise; + + /** + * Persist a product-authored message in a native chat room. + */ + postMessage( + product: ProductContext, + request: HostChatPostMessageRequest, + ): Promise; + + /** + * Emit the current product-scoped room list and later replacements. + */ + subscribeRooms( + product: ProductContext, + ): AsyncIterable; +} + /** * Core-owned administration API exposed to host UI. * diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 50465b2b..d7bdab0e 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -10,6 +10,10 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +fn nightly_toolchain() -> String { + std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| "nightly".to_string()) +} + fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { let marker = format!("export const {const_name} = ["); let start = src @@ -58,16 +62,16 @@ fn produce_rustdoc_json_for_package( target_dir: &Path, package: &str, ) -> PathBuf { - let output = Command::new("cargo") - .args(["+nightly", "rustdoc", "-p", package, "--target-dir"]) + let mut command = Command::new("cargo"); + command + .arg(format!("+{}", nightly_toolchain())) + .args(["rustdoc", "-p", package, "--target-dir"]) .arg(target_dir) .args(["--", "-Z", "unstable-options", "--output-format", "json"]) - .current_dir(workspace_root) - .output() - .expect( - "failed to spawn `cargo +nightly rustdoc`; install nightly via \ - `rustup toolchain install nightly`", - ); + .current_dir(workspace_root); + let output = command.output().expect( + "failed to spawn nightly rustdoc; install the selected nightly toolchain via rustup", + ); assert!( output.status.success(), "`cargo +nightly rustdoc -p {package}` failed (status {}); nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", @@ -99,7 +103,9 @@ fn rustfmt_generated(files: &[PathBuf]) { } let mut command = Command::new("rustfmt"); - command.args(["+nightly", "--edition", "2024"]); + command + .arg(format!("+{}", nightly_toolchain())) + .args(["--edition", "2024"]); for file in files { command.arg(file); } diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index bc7d0dd3..334deb06 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -25,7 +25,7 @@ use proc_macro2::Literal; use quote::quote; use syn::parse::{Parse, ParseStream}; use syn::{ - Attribute, Ident, ItemFn, LitInt, Token, TraitItemFn, Type, Visibility, braced, + Attribute, Ident, ItemFn, ItemTrait, LitInt, Token, TraitItemFn, Type, Visibility, braced, parse_macro_input, }; @@ -39,6 +39,37 @@ struct WireArgs { receive_id: Option, } +struct ServiceArgs { + required_execution: Ident, +} + +impl Parse for ServiceArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let key: Ident = input.parse()?; + if key != "required_execution" { + return Err(syn::Error::new(key.span(), "expected `required_execution`")); + } + input.parse::()?; + let required_execution = input.parse()?; + if !input.is_empty() { + return Err(input.error("unexpected service attribute arguments")); + } + Ok(Self { required_execution }) + } +} + +/// Declare connection-scoped middleware required by a TrUAPI service trait. +/// +/// The metadata is preserved in rustdoc JSON for `truapi-codegen`. +#[proc_macro_attribute] +pub fn service(args: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as ServiceArgs); + let mut item = parse_macro_input!(item as ItemTrait); + let tag = format!("@service_required_execution={}", args.required_execution); + item.attrs.push(syn::parse_quote!(#[doc = #tag])); + quote!(#item).into() +} + impl Parse for WireArgs { fn parse(input: ParseStream<'_>) -> syn::Result { let mut args = WireArgs::default(); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 6214ebac..7b1a5e18 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -16,13 +16,16 @@ use unicode_normalization::UnicodeNormalization; pub use async_trait::async_trait; use truapi::latest::{ - AllocatableResource, GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, - HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, - HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, - HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RemotePermission, RemotePermissionRequest, - RemotePermissionResponse, RingLocation, ThemeVariant, + AllocatableResource, GenericError, HostChatCreateRoomError, HostChatCreateRoomRequest, + HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, + HostChatPostMessageRequest, HostChatPostMessageResponse, HostDevicePermissionRequest, + HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, + HostLocalStorageReadError, HostNavigateToError, HostPushNotificationRequest, + HostPushNotificationResponse, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + NotificationId, ProductAccountId, ProductAccountTxPayload, ProductProofContext, + RemotePermission, RemotePermissionRequest, RemotePermissionResponse, RingLocation, + ThemeVariant, }; use url::Url; @@ -84,6 +87,20 @@ pub struct ProductContext { /// Host-spec C.7 defines accepted product id forms: /// pub product_id: String, + /// Trusted kind of executable attached to this connection by the host. + pub execution_kind: ProductExecutionKind, +} + +/// Trusted kind of product executable attached to a TrUAPI connection. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ProductExecutionKind { + /// Visible application entrypoint such as `app/index.html`. + #[default] + App, + /// Host-embedded product widget entrypoint. + Widget, + /// Headless worker executable that provides the Chat modality. + Chat, } /// Host metadata. @@ -177,8 +194,17 @@ impl ProductContext { /// Build a product context, validating fields whose representation cannot /// be made invalid by Rust types alone. pub fn new(product_id: String) -> Result { + Self::new_with_execution(product_id, ProductExecutionKind::App) + } + + /// Build a product context for a host-selected executable kind. + pub fn new_with_execution( + product_id: String, + execution_kind: ProductExecutionKind, + ) -> Result { Ok(Self { product_id: normalize_product_identifier(&product_id)?, + execution_kind, }) } } @@ -927,6 +953,30 @@ pub trait PreimageHost: Send + Sync { ) -> BoxStream<'static, Result>, GenericError>>; } +/// Optional native chat storage and UI adapter used by Chat executions. +#[async_trait] +pub trait ChatPlatform: Send + Sync { + /// Create or resolve a product-scoped native chat room. + async fn create_room( + &self, + product: &ProductContext, + request: HostChatCreateRoomRequest, + ) -> Result; + + /// Persist a product-authored message in a native chat room. + async fn post_message( + &self, + product: &ProductContext, + request: HostChatPostMessageRequest, + ) -> Result; + + /// Emit the current product-scoped room list and later replacements. + fn subscribe_rooms( + &self, + product: &ProductContext, + ) -> BoxStream<'static, HostChatListSubscribeItem>; +} + /// Combined platform interface. A host must provide all capability traits. pub trait Platform: Navigation diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 28aa4e8b..ea0b86ff 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -892,13 +892,56 @@ impl ChainConnection { } fn follow_with_runtime(&self, local_follow_id: &str) -> bool { + let Some(local_follow_id) = self.resolve_local_follow_id(local_follow_id) else { + return false; + }; + self.follows .lock() .unwrap() - .get(local_follow_id) + .get(&local_follow_id) .is_some_and(|follow| follow.with_runtime) } + /// Resolve the product-visible follow id to the transport-owned follow. + /// + /// Product adapters assign their own ids (for example `follow_0`) after + /// starting a subscription, while the dispatcher keys that subscription + /// by its transport request id. The first follow-bound request claims the + /// sole unaliased follow for this chain; later requests reuse that alias. + fn resolve_local_follow_id(&self, requested_id: &str) -> Option { + let mut follows = self.follows.lock().unwrap(); + + if follows.contains_key(requested_id) { + return Some(requested_id.to_string()); + } + + if let Some((local_follow_id, _)) = follows + .iter() + .find(|(_, follow)| follow.client_subscription_id.as_deref() == Some(requested_id)) + { + return Some(local_follow_id.clone()); + } + + let unaliased: Vec<_> = follows + .iter() + .filter(|(_, follow)| follow.client_subscription_id.is_none()) + .map(|(local_follow_id, _)| local_follow_id.clone()) + .take(2) + .collect(); + + let [local_follow_id] = unaliased.as_slice() else { + return None; + }; + + follows + .get_mut(local_follow_id) + .expect("unaliased follow still exists") + .client_subscription_id = Some(requested_id.to_string()); + + Some(local_follow_id.clone()) + } + fn remote_follow_id(&self, local_follow_id: &str) -> Option { self.follows .lock() @@ -928,6 +971,7 @@ impl ChainConnection { local_follow_id.to_string(), FollowState { with_runtime, + client_subscription_id: None, remote_subscription_id: None, abort: None, sender, @@ -999,24 +1043,24 @@ impl ChainConnection { method: &'static str, local_follow_id: String, ) -> Result { + let requested_id = local_follow_id; + let local_follow_id = self.resolve_local_follow_id(&requested_id).ok_or_else(|| { + RuntimeFailure::host_failure( + method, + format!("unknown follow subscription id {requested_id:?}"), + ) + })?; + if let Some(remote_follow_id) = self.remote_follow_id(&local_follow_id) { return Ok(remote_follow_id); } - let setup = { - let follows = self.follows.lock().unwrap(); - if !follows.contains_key(&local_follow_id) { - return Err(RuntimeFailure::host_failure( - method, - format!("unknown follow subscription id {local_follow_id:?}"), - )); - } - self.follow_setups - .lock() - .unwrap() - .get(&local_follow_id) - .cloned() - }; + let setup = self + .follow_setups + .lock() + .unwrap() + .get(&local_follow_id) + .cloned(); match setup { Some(setup) => setup.await.map_err(|failure| failure.reclassify(method)), @@ -1159,6 +1203,7 @@ impl ChainConnection { struct FollowState { with_runtime: bool, + client_subscription_id: Option, remote_subscription_id: Option, abort: Option, /// Local subscriber; dropping it (with the follow state) is what ends the @@ -1825,6 +1870,56 @@ mod tests { assert!(sent[1].contains("chainHead_v1_header")); } + #[test] + fn header_request_binds_product_follow_alias_to_transport_follow() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("chainHead_v1_follow") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-FOLLOW"}}"# + )) + } else if request.contains("chainHead_v1_header") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"0xdeadbeef"}}"# + )) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let _follow_stream = runtime.remote_chain_head_follow( + "transport-request-id".to_string(), + RemoteChainHeadFollowRequest { + genesis_hash: vec![0u8; 32], + with_runtime: true, + }, + ); + let sent = wait_for_sent(&provider, |sent| { + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")) + }); + assert!( + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")), + "follow setup did not start; sent: {sent:?}", + ); + + let response = futures::executor::block_on(runtime.remote_chain_head_header( + RemoteChainHeadHeaderRequest { + genesis_hash: vec![0u8; 32], + follow_subscription_id: "follow_0".to_string(), + hash: vec![1u8; 32], + }, + )) + .expect("product alias should resolve to the active transport follow"); + + assert_eq!(response.header, Some(vec![0xde, 0xad, 0xbe, 0xef])); + assert_eq!(provider.connect_calls.load(Ordering::SeqCst), 1); + let sent = provider.sent.lock().unwrap().clone(); + assert_eq!(sent.len(), 2); + assert!(sent[1].contains("chainHead_v1_header")); + } + #[test] fn transaction_stop_uses_host_handle_and_accepts_finished_provider_operation() { let provider = Arc::new(ScriptedProvider::new(|request| { diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index cb6df1d5..440e1570 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -88,7 +88,8 @@ impl TrUApiCore { spawner: Spawner, session_state: Arc, ) -> Self { - let mut dispatcher = Dispatcher::new(spawner); + let execution_kind = runtime.execution_kind(); + let mut dispatcher = Dispatcher::for_execution(spawner, execution_kind); dispatcher::register(&mut dispatcher, runtime); Self { dispatcher, diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs index 45164c61..8cbe659f 100644 --- a/rust/crates/truapi-server/src/dispatcher.rs +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -14,7 +14,10 @@ use tracing::instrument; use crate::frame::{Payload, ProtocolMessage}; use crate::generated::wire_table::{RequestFrameIds, SubscriptionFrameIds}; -use crate::subscription::{Spawner, SubscriptionManager, SubscriptionStream}; +use crate::middleware::execution::ExecutionFilter; +use crate::subscription::{ + Spawner, SubscriptionManager, SubscriptionRequestStream, SubscriptionStream, +}; use crate::transport::Transport; /// A handler for a request-response method. TrUAPI service traits require @@ -35,6 +38,17 @@ pub type SubscriptionHandler = Arc< + Sync, >; +/// Handler for a paired request and response subscription. +pub type StreamPairHandler = Arc< + dyn Fn( + String, + Vec, + SubscriptionRequestStream, + ) -> BoxFuture<'static, Result>> + + Send + + Sync, +>; + /// A registered request handler plus the discriminants it replies on. pub struct RequestEntry { ids: RequestFrameIds, @@ -47,26 +61,82 @@ pub struct SubscriptionEntry { handler: SubscriptionHandler, } +/// A registered paired-stream handler plus its frame discriminants. +pub struct StreamPairEntry { + ids: SubscriptionFrameIds, + handler: StreamPairHandler, +} + /// Routes incoming protocol messages to registered handlers, keyed on the /// numeric wire discriminant. pub struct Dispatcher { by_request: HashMap, by_start: HashMap, + by_pair_start: HashMap, + pair_receive_ids: HashSet, stop_ids: HashSet, subscriptions: SubscriptionManager, + execution: ExecutionFilter, } impl Dispatcher { /// Construct a dispatcher whose subscriptions are driven on `spawner`. pub fn new(spawner: Spawner) -> Self { + Self::with_execution_filter(spawner, ExecutionFilter::unrestricted()) + } + + /// Construct a dispatcher bound to a trusted executable kind. + pub fn for_execution( + spawner: Spawner, + execution: truapi_platform::ProductExecutionKind, + ) -> Self { + Self::with_execution_filter(spawner, ExecutionFilter::for_execution(execution)) + } + + fn with_execution_filter(spawner: Spawner, execution: ExecutionFilter) -> Self { Self { by_request: HashMap::new(), by_start: HashMap::new(), + by_pair_start: HashMap::new(), + pair_receive_ids: HashSet::new(), stop_ids: HashSet::new(), subscriptions: SubscriptionManager::new(spawner), + execution, } } + /// Return whether this connection may access a service execution kind. + pub fn allows_execution(&self, required: truapi_platform::ProductExecutionKind) -> bool { + self.execution.allows(required) + } + + /// Register a paired request/response stream handler. + pub fn on_stream_pair( + &mut self, + ids: SubscriptionFrameIds, + handler: F, + ) -> Option + where + F: Fn( + String, + Vec, + SubscriptionRequestStream, + ) -> BoxFuture<'static, Result>> + + Send + + Sync + + 'static, + { + self.stop_ids.insert(ids.stop_id); + self.pair_receive_ids.insert(ids.receive_id); + self.by_pair_start.insert( + ids.start_id, + StreamPairEntry { + ids, + handler: Arc::new(handler), + }, + ) + } + /// Register a request-response handler, keyed on `ids.request_id`. Returns /// the previously registered entry if any; callers (the generated /// `dispatcher::register`) should treat `Some` as a programming error @@ -130,6 +200,32 @@ impl Dispatcher { value, }, }); + } else if let Some(entry) = self.by_pair_start.get(&id) { + let (token, requests) = self + .subscriptions + .reserve_pair(message.request_id.clone(), entry.ids.receive_id); + let request_id = message.request_id.clone(); + match (entry.handler)(request_id, message.payload.value, requests).await { + Ok(stream) => { + self.subscriptions.activate( + token, + entry.ids.receive_id, + entry.ids.interrupt_id, + stream, + transport, + ); + } + Err(err_bytes) => { + self.subscriptions.cancel_reservation(token); + transport.send(ProtocolMessage { + request_id: message.request_id, + payload: Payload { + id: entry.ids.interrupt_id, + value: err_bytes, + }, + }); + } + } } else if let Some(entry) = self.by_start.get(&id) { // Reserve the slot before awaiting the handler so a `_stop` // arriving while the handler resolves cancels the pending @@ -157,6 +253,9 @@ impl Dispatcher { }); } } + } else if self.pair_receive_ids.contains(&id) { + self.subscriptions + .handle_request(&message.request_id, id, message.payload.value); } else if self.stop_ids.contains(&id) { self.subscriptions.handle_stop(&message.request_id); } @@ -173,6 +272,7 @@ impl Dispatcher { #[cfg(test)] mod tests { use super::*; + use futures::StreamExt; use std::sync::Mutex; fn test_spawner() -> Spawner { @@ -275,4 +375,58 @@ mod tests { "second registration must return the previous handler" ); } + + #[test] + fn paired_subscription_routes_product_values_to_its_request_stream() { + let mut dispatcher = Dispatcher::new(test_spawner()); + let ids = SubscriptionFrameIds { + start_id: 200, + stop_id: 201, + interrupt_id: 202, + receive_id: 203, + }; + dispatcher.on_stream_pair(ids, |_request_id, _bytes, requests| { + Box::pin(async move { + Ok( + Box::pin(requests.map(crate::subscription::SubscriptionOutput::Item)) + as SubscriptionStream, + ) + }) + }); + let transport = Arc::new(RecordingTransport::default()); + + futures::executor::block_on( + dispatcher.dispatch(make_frame(ids.start_id, Vec::new()), transport.clone()), + ); + futures::executor::block_on( + dispatcher.dispatch(make_frame(ids.receive_id, vec![7, 8, 9]), transport.clone()), + ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let sent = transport.sent(); + if let Some(frame) = sent.first() { + assert_eq!(frame.request_id, "p:1"); + assert_eq!(frame.payload.id, ids.receive_id); + assert_eq!(frame.payload.value, vec![7, 8, 9]); + break; + } + assert!( + std::time::Instant::now() < deadline, + "paired request was not delivered" + ); + std::thread::yield_now(); + } + } + + #[test] + fn execution_filter_is_bound_to_the_connection() { + let app = + Dispatcher::for_execution(test_spawner(), truapi_platform::ProductExecutionKind::App); + let chat = + Dispatcher::for_execution(test_spawner(), truapi_platform::ProductExecutionKind::Chat); + + assert!(!app.allows_execution(truapi_platform::ProductExecutionKind::Chat)); + assert!(chat.allows_execution(truapi_platform::ProductExecutionKind::Chat)); + } } diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index 1cefce40..e54da4bc 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -12,6 +12,7 @@ use truapi::api::{ Preimage, ResourceAllocation, Signing, StatementStore, System, Theme, }; use truapi::versioned::{self, Versioned}; +use truapi_platform::ProductExecutionKind; use crate::dispatcher::Dispatcher; use crate::frame::encode_versioned_err_payload; @@ -19,7 +20,7 @@ use crate::frame::encode_versioned_interrupt_payload; use crate::frame::encode_versioned_ok_payload; use crate::frame::encode_versioned_unit_ok_payload; use crate::generated::wire_table; -use crate::subscription::subscription_stream; +use crate::subscription::{subscription_request_stream, subscription_stream}; /// Register every TrUAPI method with the dispatcher. pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) @@ -752,6 +753,7 @@ where P: Chat + Send + Sync + 'static, { { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_request( wire_table::CHAT_CREATE_ROOM, @@ -775,6 +777,11 @@ where }; let target_version = request.version(); let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } let response: versioned::chat::HostChatCreateRoomResponse = match host.create_room(&cx, request).await { Ok(value) => value, @@ -788,6 +795,7 @@ where ); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_request( wire_table::CHAT_REGISTER_BOT, @@ -811,6 +819,11 @@ where }; let target_version = request.version(); let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } let response: versioned::chat::HostChatRegisterBotResponse = match host.register_bot(&cx, request).await { Ok(value) => value, @@ -824,6 +837,7 @@ where ); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_subscription( wire_table::CHAT_LIST_SUBSCRIBE, @@ -832,6 +846,9 @@ where Box::pin(async move { let _ = bytes; let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + return Err(Vec::new()); + } let stream = host.list_subscribe(&cx).await; Ok(subscription_stream::< versioned::chat::HostChatListSubscribeItem, @@ -842,6 +859,7 @@ where ); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_request( wire_table::CHAT_POST_MESSAGE, @@ -865,6 +883,11 @@ where }; let target_version = request.version(); let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } let response: versioned::chat::HostChatPostMessageResponse = match host.post_message(&cx, request).await { Ok(value) => value, @@ -878,6 +901,7 @@ where ); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host.clone(); dispatcher.on_subscription( wire_table::CHAT_ACTION_SUBSCRIBE, @@ -886,6 +910,9 @@ where Box::pin(async move { let _ = bytes; let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + return Err(Vec::new()); + } let stream = host.action_subscribe(&cx).await; Ok(subscription_stream::< versioned::chat::HostChatActionSubscribeItem, @@ -896,19 +923,22 @@ where ); } { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host; - dispatcher.on_subscription( + dispatcher.on_stream_pair( wire_table::CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE, - move |request_id: String, bytes: Vec| { + move |request_id: String, bytes: Vec, requests| { let host = host.clone(); Box::pin(async move { - let request: versioned::chat::ProductChatCustomMessageRenderSubscribeRequest = - match Decode::decode(&mut &bytes[..]) { - Ok(request) => request, - Err(_) => return Err(Vec::new()), - }; + let _ = bytes; let cx = CallContext::with_request_id(request_id.clone()); - let stream = host.custom_message_render_subscribe(&cx, request).await; + if !execution_allowed { + return Err(Vec::new()); + } + let requests = subscription_request_stream::< + versioned::chat::ProductChatCustomMessageRenderSubscribeRequest, + >(requests); + let stream = host.custom_message_render_subscribe(&cx, requests).await; Ok(subscription_stream::< versioned::chat::ProductChatCustomMessageRenderSubscribeItem, _, diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 2e684951..b8958f85 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -18,6 +18,7 @@ use parity_scale_codec::{Decode, Encode}; use thiserror::Error; use tracing::instrument; use truapi::v01; +use truapi_platform::ChatPlatform; use truapi_platform::{ CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, @@ -51,6 +52,18 @@ pub enum ProductRuntimeError { /// Decode failure reason. reason: String, }, + /// The connection execution kind does not allow Chat operations. + #[error("chat operation denied for this execution")] + Denied, + /// The product connection has already closed. + #[error("product connection is closed")] + Closed, + /// The product or native host did not install the requested Chat surface. + #[error("chat operation is unsupported")] + Unsupported, + /// The bounded pre-subscription action queue is full. + #[error("chat action buffer is full")] + BufferFull, } fn product_context(product_id: &str) -> Result { @@ -91,6 +104,32 @@ impl PairingHostRuntime { } } + /// Build a long-lived pairing host with a native Chat adapter. + pub fn new_with_chat

( + platform: Arc

, + chat: Arc, + config: PairingHostConfig, + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = RuntimeServices::new_with_chat( + platform, + Some(chat), + config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHostRole::new(services.clone(), config); + pairing_host.clone().start_session_store_sync(spawner); + Self { + services, + pairing_host, + } + } + /// Build a product-facing runtime from this pairing host. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_runtime"))] pub fn product_runtime( @@ -255,6 +294,31 @@ impl SigningHostRuntime { } } + /// Build a long-lived signing host with a native Chat adapter. + pub fn new_with_chat

( + platform: Arc

, + chat: Arc, + config: SigningHostConfig, + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = RuntimeServices::new_with_chat( + platform, + Some(chat), + config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, + spawner, + ); + let signing_host = SigningHostRole::new(services.clone()); + Self { + services, + signing_host, + } + } + /// Build a product-facing runtime from this signing host. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_runtime"))] pub fn product_runtime( @@ -270,12 +334,58 @@ impl SigningHostRuntime { ) } + /// Build one product connection with connection-specific platform + /// adapters while sharing this runtime's authentication and core services. + pub fn product_runtime_with_platform( + &self, + product: ProductContext, + platform: Arc, + chat: Option>, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new_with_platform( + self.services.clone(), + self.signing_host.clone(), + product, + platform, + chat, + sink, + ) + } + /// Build a product-scoped administration handle from this signing host. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_admin"))] pub fn product_admin(&self, product: ProductContext) -> HostAdmin { HostAdmin::new(self.services.clone(), self.signing_host.clone(), product) } + /// Build a product administration handle with adapters scoped to one + /// native executable connection. + pub fn product_admin_with_platform( + &self, + product: ProductContext, + platform: Arc, + chat: Option>, + ) -> HostAdmin { + HostAdmin::new_with_platform( + self.services.clone(), + self.signing_host.clone(), + product, + platform, + chat, + ) + } + + /// Return whether this host currently has an authenticated signing session. + pub fn has_active_session(&self) -> bool { + self.signing_host.session_state().current().is_some() + } + + /// Return whether this host installed a native Chat adapter. + pub fn supports_chat(&self) -> bool { + self.services.chat.is_some() + } + /// Disconnect the active account-authority session. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_session"))] pub async fn disconnect_session(&self) { @@ -351,6 +461,28 @@ impl HostAdmin { } } + /// Build an admin handle with adapters scoped to one executable + /// connection while retaining the shared host authority. + pub(crate) fn new_with_platform( + services: Arc, + authority: Arc, + product: ProductContext, + platform: Arc, + chat: Option>, + ) -> Self { + let product_runtime = Arc::new(ProductRuntimeHost::from_services_with_platform( + services, + platform, + chat, + authority.clone(), + product, + )); + Self { + authority, + product_runtime, + } + } + /// Core-owned logout/disconnect. #[instrument(skip_all, fields(runtime.method = "host_admin.disconnect_session"))] pub async fn disconnect_session(&self) { @@ -436,6 +568,40 @@ pub struct ProductRuntime { next_dispatch_id: AtomicU64, } +/// Cloneable native control surface for one concrete product connection. +#[derive(Clone)] +pub struct ProductRuntimeControl { + runtime: Arc, + disposed: Arc, +} + +impl ProductRuntimeControl { + /// Publish a native Chat action to this connection. + pub fn publish_chat_action( + &self, + action: v01::HostChatActionSubscribeItem, + ) -> Result<(), ProductRuntimeError> { + if self.disposed.load(Ordering::Acquire) { + return Err(ProductRuntimeError::Closed); + } + self.runtime.publish_chat_action(action) + } + + /// Request custom-message UI from this connection's product renderer. + pub fn render_custom_message( + &self, + message_id: String, + message_type: String, + payload: Vec, + ) -> Result, ProductRuntimeError> { + if self.disposed.load(Ordering::Acquire) { + return Err(ProductRuntimeError::Closed); + } + self.runtime + .render_custom_message(message_id, message_type, payload) + } +} + impl ProductRuntime { /// Build a product-facing host core around a platform implementation and /// outgoing frame sink. @@ -482,6 +648,42 @@ impl ProductRuntime { } } + /// Build one connection using execution-scoped platform adapters while + /// sharing host-level authentication and infrastructure. + pub(crate) fn new_with_platform( + services: Arc, + authority: Arc, + product: ProductContext, + platform: Arc, + chat: Option>, + sink: Arc, + ) -> Self { + let disposed = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(SinkTransport { + sink, + disposed: disposed.clone(), + }); + let admin = HostAdmin::new_with_platform( + services.clone(), + authority.clone(), + product, + platform, + chat, + ); + Self { + core: TrUApiCore::from_product_runtime( + admin.product_runtime.clone(), + services.spawner.clone(), + authority.session_state(), + ), + admin, + transport, + disposed, + in_flight: Mutex::new(HashMap::new()), + next_dispatch_id: AtomicU64::new(0), + } + } + /// Push one SCALE-encoded protocol frame into the dispatcher. /// /// Calls after [`Self::dispose`] are ignored and return `Ok(())` without @@ -518,6 +720,40 @@ impl ProductRuntime { Ok(()) } + /// Publish a native Chat action to this connection's action subscription. + pub fn publish_chat_action( + &self, + action: v01::HostChatActionSubscribeItem, + ) -> Result<(), ProductRuntimeError> { + if self.disposed.load(Ordering::Acquire) { + return Err(ProductRuntimeError::Closed); + } + self.admin.product_runtime.publish_chat_action(action) + } + + /// Return a cloneable native control handle bound to this connection. + pub fn control(&self) -> ProductRuntimeControl { + ProductRuntimeControl { + runtime: self.admin.product_runtime.clone(), + disposed: self.disposed.clone(), + } + } + + /// Request native UI for a stored custom message from the product renderer. + pub fn render_custom_message( + &self, + message_id: String, + message_type: String, + payload: Vec, + ) -> Result, ProductRuntimeError> { + if self.disposed.load(Ordering::Acquire) { + return Err(ProductRuntimeError::Closed); + } + self.admin + .product_runtime + .render_custom_message(message_id, message_type, payload) + } + /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when /// the session has channel material, then clears in-memory and persisted /// session state. @@ -574,6 +810,7 @@ impl ProductRuntime { { handle.abort(); } + self.admin.product_runtime.close_chat(); self.core.cancel_subscriptions(); } } @@ -625,6 +862,16 @@ mod tests { fn assert_send_sync() {} + fn text_action(text: &str) -> v01::HostChatActionSubscribeItem { + v01::HostChatActionSubscribeItem { + room_id: "room".to_string(), + peer: "alice".to_string(), + payload: v01::ChatActionPayload::MessagePosted(v01::ChatMessageContent::Text { + text: text.to_string(), + }), + } + } + #[test] fn product_runtime_and_dispatch_future_are_send() { assert_send_sync::(); @@ -640,6 +887,141 @@ mod tests { assert_send(runtime.receive_frame(Vec::new())); } + #[test] + fn app_connection_rejects_native_chat_actions() { + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + Arc::new(RecordingSink::default()), + ); + + assert!(matches!( + runtime.publish_chat_action(text_action("hello")), + Err(ProductRuntimeError::Denied) + )); + } + + #[test] + fn app_connection_rejects_native_custom_rendering() { + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + Arc::new(RecordingSink::default()), + ); + + assert!(matches!( + runtime.render_custom_message("message".into(), "vote".into(), vec![]), + Err(ProductRuntimeError::Denied) + )); + } + + #[test] + fn generated_filter_denies_chat_request_on_app_connection() { + let sink = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + sink.clone(), + ); + let ids = crate::frame::request_ids("chat_create_room").expect("known Chat request"); + let request = truapi::versioned::chat::HostChatCreateRoomRequest::V1( + v01::HostChatCreateRoomRequest { + room_id: "room".into(), + name: "Room".into(), + icon: String::new(), + }, + ); + let frame = ProtocolMessage { + request_id: "chat:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + let frames = sink.frames.lock().unwrap(); + assert_eq!(frames.len(), 1); + let response = ProtocolMessage::decode(&mut frames[0].as_slice()).unwrap(); + assert_eq!(response.payload.id, ids.response_id); + let expected = crate::frame::encode_versioned_err_payload( + truapi::CallError::::Denied, + 1, + ); + assert_eq!(response.payload.value, expected); + } + + #[test] + fn generated_filter_denies_chat_subscription_on_app_connection() { + let sink = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + sink.clone(), + ); + let ids = subscription_ids("chat_action_subscribe").expect("known Chat subscription"); + let frame = ProtocolMessage { + request_id: "chat:actions".into(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + let frames = sink.frames.lock().unwrap(); + assert_eq!(frames.len(), 1); + let response = ProtocolMessage::decode(&mut frames[0].as_slice()).unwrap(); + assert_eq!(response.request_id, "chat:actions"); + assert_eq!(response.payload.id, ids.interrupt_id); + assert!(response.payload.value.is_empty()); + } + + #[test] + fn generated_filter_denies_renderer_stream_pair_on_app_connection() { + let sink = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + sink.clone(), + ); + let ids = subscription_ids("chat_custom_message_render_subscribe") + .expect("known renderer stream pair"); + let frame = ProtocolMessage { + request_id: "chat:renderer".into(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + let frames = sink.frames.lock().unwrap(); + assert_eq!(frames.len(), 1); + let response = ProtocolMessage::decode(&mut frames[0].as_slice()).unwrap(); + assert_eq!(response.request_id, "chat:renderer"); + assert_eq!(response.payload.id, ids.interrupt_id); + assert!(response.payload.value.is_empty()); + } + #[test] fn dispose_cancels_active_subscriptions() { let theme_stream_dropped = Arc::new(AtomicBool::new(false)); diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index cf10b215..427f1773 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -21,6 +21,7 @@ pub(crate) mod host_core; pub mod host_logic; pub(crate) mod host_rpc_client; pub mod logging; +pub mod middleware; pub(crate) mod runtime; pub mod subscription; pub mod transport; @@ -36,12 +37,15 @@ pub mod ws_bridge; #[cfg(not(target_arch = "wasm32"))] pub mod native; +#[cfg(not(target_arch = "wasm32"))] +pub mod native_renderer; + #[cfg(target_arch = "wasm32")] pub mod wasm; pub use host_core::{ - FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, - SigningHostRuntime, + FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeControl, + ProductRuntimeError, SigningHostRuntime, }; pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] @@ -57,6 +61,9 @@ pub use ws_bridge::*; #[cfg(not(target_arch = "wasm32"))] pub use native::*; +#[cfg(not(target_arch = "wasm32"))] +pub use native_renderer::*; + #[cfg(target_arch = "wasm32")] pub use wasm::*; diff --git a/rust/crates/truapi-server/src/middleware/execution.rs b/rust/crates/truapi-server/src/middleware/execution.rs new file mode 100644 index 00000000..2ab19d99 --- /dev/null +++ b/rust/crates/truapi-server/src/middleware/execution.rs @@ -0,0 +1,28 @@ +//! Trusted executable-kind filtering for service surfaces. + +use truapi_platform::ProductExecutionKind; + +/// Immutable execution-kind filter bound to one product connection. +#[derive(Debug, Clone, Copy)] +pub struct ExecutionFilter { + actual: Option, +} + +impl ExecutionFilter { + /// Build an unrestricted filter for direct dispatcher embeddings. + pub fn unrestricted() -> Self { + Self { actual: None } + } + + /// Build a filter for a host-assigned executable kind. + pub fn for_execution(actual: ProductExecutionKind) -> Self { + Self { + actual: Some(actual), + } + } + + /// Return whether the connection may access a service requiring `required`. + pub fn allows(&self, required: ProductExecutionKind) -> bool { + self.actual.is_none_or(|actual| actual == required) + } +} diff --git a/rust/crates/truapi-server/src/middleware/mod.rs b/rust/crates/truapi-server/src/middleware/mod.rs new file mode 100644 index 00000000..ed1867ea --- /dev/null +++ b/rust/crates/truapi-server/src/middleware/mod.rs @@ -0,0 +1,3 @@ +//! Connection-scoped middleware applied before TrUAPI service handlers. + +pub mod execution; diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index eab2d7bb..ab617824 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -9,8 +9,10 @@ //! the pairing-host-only entry points are inert. use std::collections::HashMap; +#[cfg(feature = "ws-bridge")] +use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use futures::channel::mpsc; use futures::executor::ThreadPool; @@ -23,15 +25,28 @@ use truapi_platform::{ AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, JsonRpcConnection, Navigation, Notifications, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Permissions, PlatformInfo, PreimageHost, ProductContext, - ProductStorage, RuntimeConfigValidationError, SigningHostConfig, ThemeHost, UserConfirmation, - UserConfirmationReview, async_trait, + ProductExecutionKind, ProductStorage, RuntimeConfigValidationError, SigningHostConfig, + ThemeHost, UserConfirmation, UserConfirmationReview, async_trait, }; use crate::SigningHostRuntime; +#[cfg(feature = "ws-bridge")] +use crate::native_renderer::observe_renderer; +use crate::native_renderer::{NativeCustomRendererObserver, NativeCustomRendererSubscription}; use crate::subscription::Spawner; #[cfg(feature = "ws-bridge")] use crate::ws_bridge::{BridgeLogger, WsBridge, WsBridgeEndpoint, WsBridgeStartError}; +#[cfg(feature = "ws-bridge")] +const NATIVE_CHAT_ACTION_BUFFER_CAPACITY: usize = 64; + +#[cfg(feature = "ws-bridge")] +#[derive(Default)] +struct NativeProductControlState { + control: Option, + pending_chat_actions: VecDeque, +} + /// Native-friendly storage error. Mirrors the v0.1 wire shape so the /// callback surface stays SCALE-free. #[derive(Debug, Clone, thiserror::Error, uniffi::Error)] @@ -189,6 +204,159 @@ pub enum NativePairingDeeplinkScheme { PolkadotAppDev, } +/// Trusted executable kind selected by the native host. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, uniffi::Enum)] +pub enum NativeProductExecutionKind { + /// Visible application entrypoint. + #[default] + App, + /// Host-embedded product widget entrypoint. + Widget, + /// Headless Chat worker entrypoint. + Chat, +} + +impl From for ProductExecutionKind { + fn from(kind: NativeProductExecutionKind) -> Self { + match kind { + NativeProductExecutionKind::App => Self::App, + NativeProductExecutionKind::Widget => Self::Widget, + NativeProductExecutionKind::Chat => Self::Chat, + } + } +} + +/// Native mirror of the room registration outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeChatRoomRegistrationStatus { + /// The native host created the room. + New, + /// The native host already had the room. + Exists, +} + +/// One product-scoped native Chat room. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeChatRoom { + /// Product-local room identifier. + pub room_id: String, + /// Whether the product owns the room or participates as a bot. + pub is_host: bool, +} + +/// Native Chat action published to a product worker. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum NativeChatAction { + /// A user posted a text message. + MessagePostedText { + /// Product-local room identifier. + room_id: String, + /// Host-derived peer identifier. + peer: String, + /// User-entered text. + text: String, + }, + /// A user triggered an opaque action emitted by a message or widget. + ActionTriggered { + /// Product-local room identifier. + room_id: String, + /// Host-derived peer identifier. + peer: String, + /// Message containing the action. + message_id: String, + /// Product-defined action identifier. + action_id: String, + /// Optional product-defined action payload. + payload: Option>, + }, + /// A user submitted a command. + Command { + /// Product-local room identifier. + room_id: String, + /// Host-derived peer identifier. + peer: String, + /// Command name. + command: String, + /// Command arguments. + payload: String, + }, +} + +impl From for v01::HostChatActionSubscribeItem { + fn from(action: NativeChatAction) -> Self { + match action { + NativeChatAction::MessagePostedText { + room_id, + peer, + text, + } => Self { + room_id, + peer, + payload: v01::ChatActionPayload::MessagePosted(v01::ChatMessageContent::Text { + text, + }), + }, + NativeChatAction::ActionTriggered { + room_id, + peer, + message_id, + action_id, + payload, + } => Self { + room_id, + peer, + payload: v01::ChatActionPayload::ActionTriggered(v01::ActionTrigger { + message_id, + action_id, + payload, + }), + }, + NativeChatAction::Command { + room_id, + peer, + command, + payload, + } => Self { + room_id, + peer, + payload: v01::ChatActionPayload::Command(v01::ChatCommand { command, payload }), + }, + } + } +} + +/// Native failure while routing Chat work to a product connection. +#[derive(Debug, Clone, thiserror::Error, uniffi::Error)] +pub enum NativeChatError { + /// No connected product runtime is available. + #[error("chat product is not connected")] + NotConnected, + /// The connected executable is not a Chat worker. + #[error("chat operation denied for this execution")] + Denied, + /// The product connection has closed. + #[error("chat product connection is closed")] + Closed, + /// The product or host did not install the requested Chat surface. + #[error("chat operation is unsupported")] + Unsupported, + /// The bounded startup action buffer is full. + #[error("chat action buffer is full")] + BufferFull, +} + +impl From for NativeChatError { + fn from(error: crate::ProductRuntimeError) -> Self { + match error { + crate::ProductRuntimeError::Denied => Self::Denied, + crate::ProductRuntimeError::Closed => Self::Closed, + crate::ProductRuntimeError::Unsupported => Self::Unsupported, + crate::ProductRuntimeError::BufferFull => Self::BufferFull, + crate::ProductRuntimeError::InvalidFrame { .. } => Self::Unsupported, + } + } +} + /// Native-friendly mirror of [`PermissionAuthorizationStatus`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] pub enum NativePermissionAuthorizationStatus { @@ -225,6 +393,8 @@ impl From for PermissionAuthorizationStatus pub struct NativeRuntimeConfig { /// Canonical product identifier used for account derivation. pub product_id: String, + /// Trusted executable kind derived by the native host before loading it. + pub execution_kind: NativeProductExecutionKind, /// Host name shown by the wallet during SSO pairing. pub host_name: String, /// Optional host icon URL shown by the wallet during SSO pairing. @@ -247,6 +417,45 @@ pub struct NativeRuntimeConfig { pub pairing_deeplink_scheme: NativePairingDeeplinkScheme, } +/// Process-owned native host configuration shared by every product execution. +#[derive(Debug, Clone, uniffi::Record)] +pub struct NativeHostRuntimeConfig { + /// Host name shown by the wallet during SSO pairing. + pub host_name: String, + /// Optional host icon URL shown by the wallet during SSO pairing. + pub host_icon: Option, + /// Optional host version shown by the wallet during SSO pairing. + pub host_version: Option, + /// Optional platform/browser name shown by the wallet during SSO pairing. + pub platform_type: Option, + /// Optional platform/browser version shown by the wallet during SSO pairing. + pub platform_version: Option, + /// People-chain genesis hash. Must be exactly 32 bytes. + pub people_chain_genesis_hash: Vec, + /// Bulletin-chain genesis hash. Must be exactly 32 bytes. + pub bulletin_chain_genesis_hash: Vec, + /// Optional local signing-host secret material (raw BIP-39 entropy). + pub local_session_secret: Option>, + /// Optional lite username attached to the local signing-host session. + pub local_session_lite_username: Option, +} + +/// Trusted identity attached by a native host to one executable connection. +#[derive(Debug, Clone, uniffi::Record)] +pub struct NativeProductExecutionConfig { + /// Canonical product identifier used for policy, storage, and derivation. + pub product_id: String, + /// Trusted executable kind selected before product code starts. + pub execution_kind: NativeProductExecutionKind, +} + +#[derive(Debug)] +struct NativeResolvedHostRuntimeConfig { + signing: SigningHostConfig, + local_session_secret: Option>, + local_session_lite_username: Option, +} + #[derive(Debug)] struct NativeResolvedRuntimeConfig { signing: SigningHostConfig, @@ -325,7 +534,8 @@ impl TryFrom for NativeResolvedRuntimeConfig { } })?; let product = - ProductContext::new(config.product_id).map_err(NativeRuntimeConfigError::from)?; + ProductContext::new_with_execution(config.product_id, config.execution_kind.into()) + .map_err(NativeRuntimeConfigError::from)?; let signing = SigningHostConfig::new( HostInfo { name: config.host_name, @@ -348,6 +558,52 @@ impl TryFrom for NativeResolvedRuntimeConfig { } } +impl TryFrom for NativeResolvedHostRuntimeConfig { + type Error = NativeRuntimeConfigError; + + fn try_from(config: NativeHostRuntimeConfig) -> Result { + let people_chain_genesis_hash = + <[u8; 32]>::try_from(config.people_chain_genesis_hash.as_slice()).map_err(|_| { + NativeRuntimeConfigError::InvalidPeopleChainGenesisHash { + actual: config.people_chain_genesis_hash.len() as u64, + } + })?; + let bulletin_chain_genesis_hash = + <[u8; 32]>::try_from(config.bulletin_chain_genesis_hash.as_slice()).map_err(|_| { + NativeRuntimeConfigError::InvalidBulletinChainGenesisHash { + actual: config.bulletin_chain_genesis_hash.len() as u64, + } + })?; + let signing = SigningHostConfig::new( + HostInfo { + name: config.host_name, + icon: config.host_icon, + version: config.host_version, + }, + PlatformInfo { + kind: config.platform_type, + version: config.platform_version, + }, + people_chain_genesis_hash, + bulletin_chain_genesis_hash, + )?; + Ok(Self { + signing, + local_session_secret: config.local_session_secret, + local_session_lite_username: config.local_session_lite_username, + }) + } +} + +impl TryFrom for ProductContext { + type Error = NativeRuntimeConfigError; + + fn try_from(config: NativeProductExecutionConfig) -> Result { + ProductContext::new_with_execution(config.product_id, config.execution_kind.into()) + .map_err(NativeRuntimeConfigError::from) + } +} + impl From for NativeRuntimeConfigError { fn from(err: RuntimeConfigValidationError) -> Self { match err { @@ -474,18 +730,460 @@ pub trait HostCallbacks: Send + Sync { fn local_storage_write(&self, key: String, value: Vec) -> Result<(), HostStorageError>; /// Clear a value from the host's scoped key-value store. fn local_storage_clear(&self, key: String) -> Result<(), HostStorageError>; + + /// Return whether this native host installed a Chat storage and UI adapter. + fn chat_supported(&self) -> bool { + false + } + + /// Create or resolve a native product Chat room. + fn chat_create_room( + &self, + room_id: String, + name: String, + icon: String, + ) -> Result { + let _ = (room_id, name, icon); + Err(HostRejection::Rejected { + reason: "native Chat adapter unavailable".to_string(), + }) + } + + /// Persist a text message in native Chat storage. + fn chat_post_text_message( + &self, + room_id: String, + text: String, + ) -> Result { + let _ = (room_id, text); + Err(HostRejection::Rejected { + reason: "native Chat adapter unavailable".to_string(), + }) + } + + /// Persist a custom message in native Chat storage. + fn chat_post_custom_message( + &self, + room_id: String, + message_type: String, + payload: Vec, + ) -> Result { + let _ = (room_id, message_type, payload); + Err(HostRejection::Rejected { + reason: "native Chat adapter unavailable".to_string(), + }) + } + + /// Return the current product-scoped native Chat room list. + fn chat_list_rooms(&self) -> Result, HostRejection> { + Ok(Vec::new()) + } +} + +/// Process-owned native TrUAPI runtime shared by all executable connections. +#[derive(uniffi::Object)] +pub struct NativeTrUApiHostRuntime { + runtime: Arc, + events: Arc, + #[cfg(feature = "ws-bridge")] + spawner: Spawner, + _callbacks: Arc, + chat_executions: Mutex>>, +} + +#[uniffi::export] +impl NativeTrUApiHostRuntime { + /// Construct one host-level runtime and optionally activate its local session. + #[uniffi::constructor] + pub fn with_runtime_config( + callbacks: Box, + runtime_config: NativeHostRuntimeConfig, + ) -> Result, NativeRuntimeConfigError> { + crate::logging::init(); + let runtime_config: NativeResolvedHostRuntimeConfig = runtime_config.try_into()?; + let callbacks: Arc = callbacks.into(); + callbacks.on_core_log( + "truapi.native.host_runtime.boot".to_string(), + "host runtime ready".to_string(), + ); + let events = Arc::new(NativeEventBus::default()); + let platform = Arc::new(CallbackPlatform { + callbacks: callbacks.clone(), + events: events.clone(), + }); + let spawner = native_thread_pool_spawner(&callbacks); + let runtime = Arc::new(SigningHostRuntime::new( + platform, + runtime_config.signing, + spawner.clone(), + )); + if let Some(secret) = runtime_config.local_session_secret { + futures::executor::block_on(runtime.activate_local_session_with_identity( + secret, + runtime_config.local_session_lite_username, + )) + .map_err(|err| NativeRuntimeConfigError::LocalSessionActivation { + reason: err.reason, + })?; + } + Ok(Arc::new(Self { + runtime, + events, + #[cfg(feature = "ws-bridge")] + spawner, + _callbacks: callbacks, + chat_executions: Mutex::new(HashMap::new()), + })) + } + + /// Open a connection-scoped execution with immutable trusted context. + pub fn open_product_execution( + &self, + callbacks: Box, + execution_config: NativeProductExecutionConfig, + ) -> Result, NativeRuntimeConfigError> { + let product: ProductContext = execution_config.try_into()?; + let callbacks: Arc = callbacks.into(); + let events = Arc::new(NativeEventBus::default()); + let concrete_platform = Arc::new(CallbackPlatform { + callbacks: callbacks.clone(), + events: events.clone(), + }); + let platform: Arc = concrete_platform.clone(); + let chat: Option> = callbacks + .chat_supported() + .then(|| concrete_platform.clone() as Arc); + let execution = Arc::new(NativeProductExecution { + runtime: self.runtime.clone(), + product: product.clone(), + platform, + chat, + events, + #[cfg(feature = "ws-bridge")] + spawner: self.spawner.clone(), + #[cfg(feature = "ws-bridge")] + callbacks, + closed: AtomicBool::new(false), + #[cfg(feature = "ws-bridge")] + bridge: Mutex::new(None), + #[cfg(feature = "ws-bridge")] + product_control: Arc::new(Mutex::new(NativeProductControlState::default())), + }); + + if product.execution_kind == ProductExecutionKind::Chat { + let previous = self + .chat_executions + .lock() + .expect("native Chat execution registry mutex poisoned") + .insert(product.product_id, Arc::downgrade(&execution)) + .and_then(|previous| previous.upgrade()); + if let Some(previous) = previous { + previous.close(); + } + } + + Ok(execution) + } + + /// Core-owned logout for the process-wide authentication session. + pub fn disconnect(&self) { + futures::executor::block_on(self.runtime.disconnect_session()); + } + + /// Activate or replace the process-wide local signing session. + pub fn activate_local_session( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), HostRejection> { + futures::executor::block_on( + self.runtime + .activate_local_session_with_identity(secret, lite_username), + ) + .map_err(Into::into) + } + + /// Notify the shared chain adapter of one JSON-RPC response. + pub fn notify_chain_response(&self, connection_id: u32, json: String) { + self.events.notify_chain_response(connection_id, json); + } + + /// Notify the shared chain adapter that a connection closed. + pub fn notify_chain_closed(&self, connection_id: u32) { + self.events.notify_chain_closed(connection_id); + } + + /// Retained compatibility hook; native signing hosts own session state in memory. + pub fn notify_session_store_changed(&self) {} + + /// Retained compatibility hook; native signing hosts have no pairing login. + pub fn cancel_login(&self) {} +} + +/// One native executable connection opened from a process-owned host runtime. +#[derive(uniffi::Object)] +pub struct NativeProductExecution { + runtime: Arc, + product: ProductContext, + platform: Arc, + chat: Option>, + events: Arc, + #[cfg(feature = "ws-bridge")] + spawner: Spawner, + #[cfg(feature = "ws-bridge")] + callbacks: Arc, + closed: AtomicBool, + #[cfg(feature = "ws-bridge")] + bridge: Mutex>, + #[cfg(feature = "ws-bridge")] + product_control: Arc>, +} + +#[uniffi::export] +impl NativeProductExecution { + /// Read a product-scoped permission authorization without prompting. + pub fn permission_authorization_status( + &self, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let admin = self.runtime.product_admin_with_platform( + self.product.clone(), + self.platform.clone(), + self.chat.clone(), + ); + let status = futures::executor::block_on(admin.permission_authorization_status(request))?; + Ok(status.into()) + } + + /// Update a product-scoped permission authorization. + pub fn set_permission_authorization_status( + &self, + payload: Vec, + status: NativePermissionAuthorizationStatus, + ) -> Result<(), HostRejection> { + let request = decode_permission_authorization_request(&payload)?; + let admin = self.runtime.product_admin_with_platform( + self.product.clone(), + self.platform.clone(), + self.chat.clone(), + ); + futures::executor::block_on( + admin.set_permission_authorization_status(request, status.into()), + )?; + Ok(()) + } + + /// Push a host theme replacement to this execution's subscriptions. + pub fn notify_theme_changed(&self, theme: HostTheme) { + self.events.notify_theme_changed(theme.into()); + } + + /// Push a preimage lookup replacement to this execution's subscriptions. + pub fn notify_preimage_changed(&self, key: Vec, value: Option>) { + self.events.notify_preimage_changed(&key, value); + } + + /// Push a complete native Chat room-list replacement to this execution. + pub fn notify_chat_rooms_changed(&self, rooms: Vec) { + self.events.notify_chat_rooms_changed(rooms); + } + + /// Publish one native Chat action, buffering it until the connection opens. + pub fn publish_chat_action(&self, action: NativeChatAction) -> Result<(), NativeChatError> { + if self.closed.load(Ordering::Acquire) { + return Err(NativeChatError::Closed); + } + if self.product.execution_kind != ProductExecutionKind::Chat + || !self.runtime.has_active_session() + { + return Err(NativeChatError::Denied); + } + if self.chat.is_none() { + return Err(NativeChatError::Unsupported); + } + + #[cfg(feature = "ws-bridge")] + { + let action: v01::HostChatActionSubscribeItem = action.into(); + let mut state = self + .product_control + .lock() + .expect("native product control mutex poisoned"); + if let Some(control) = state.control.as_ref() { + match control.publish_chat_action(action.clone()) { + Ok(()) => return Ok(()), + Err(crate::ProductRuntimeError::Closed) => state.control = None, + Err(error) => return Err(error.into()), + } + } + if state.pending_chat_actions.len() == NATIVE_CHAT_ACTION_BUFFER_CAPACITY { + return Err(NativeChatError::BufferFull); + } + state.pending_chat_actions.push_back(action); + Ok(()) + } + #[cfg(not(feature = "ws-bridge"))] + { + let _ = action; + Err(NativeChatError::NotConnected) + } + } + + /// Request typed native UI for one stored custom Chat message. + pub fn render_custom_message( + &self, + message_id: String, + message_type: String, + payload: Vec, + observer: Box, + ) -> Result, NativeChatError> { + if self.closed.load(Ordering::Acquire) { + return Err(NativeChatError::Closed); + } + if self.product.execution_kind != ProductExecutionKind::Chat + || !self.runtime.has_active_session() + { + return Err(NativeChatError::Denied); + } + if self.chat.is_none() { + return Err(NativeChatError::Unsupported); + } + #[cfg(feature = "ws-bridge")] + { + let control = self + .product_control + .lock() + .expect("native product control mutex poisoned") + .control + .clone() + .ok_or(NativeChatError::NotConnected)?; + let stream = control + .render_custom_message(message_id, message_type, payload) + .map_err(NativeChatError::from)?; + let observer: Arc = observer.into(); + Ok(observe_renderer(stream, observer, self.spawner.clone())) + } + #[cfg(not(feature = "ws-bridge"))] + { + let _ = (message_id, message_type, payload, observer); + Err(NativeChatError::NotConnected) + } + } + + /// Permanently close this executable and all of its connection state. + pub fn close(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + #[cfg(feature = "ws-bridge")] + { + if let Some(mut bridge) = self + .bridge + .lock() + .expect("native product bridge mutex poisoned") + .take() + { + bridge.stop(); + } + *self + .product_control + .lock() + .expect("native product control mutex poisoned") = + NativeProductControlState::default(); + } + } } -/// UniFFI object exposing the TrUAPI core to native hosts. +#[cfg(feature = "ws-bridge")] +#[uniffi::export] +impl NativeProductExecution { + /// Start this execution's independently authenticated localhost bridge. + pub fn start_ws_bridge(&self, bind_port: u16) -> Result { + if self.closed.load(Ordering::Acquire) { + return Err(WsBridgeStartError::Io( + "product execution is closed".to_string(), + )); + } + let mut guard = self + .bridge + .lock() + .expect("native product bridge mutex poisoned"); + if guard.is_some() { + return Err(WsBridgeStartError::AlreadyRunning); + } + let logger: BridgeLogger = { + let callbacks = self.callbacks.clone(); + Arc::new(move |marker: &str, detail: &str| { + callbacks.on_core_log(marker.to_string(), detail.to_string()); + }) + }; + let runtime = self.runtime.clone(); + let product = self.product.clone(); + let platform = self.platform.clone(); + let chat = self.chat.clone(); + let product_control = self.product_control.clone(); + let runtime_factory = Arc::new(move |sink| { + let product_runtime = runtime.product_runtime_with_platform( + product.clone(), + platform.clone(), + chat.clone(), + sink, + ); + let control = product_runtime.control(); + let mut state = product_control + .lock() + .expect("native product control mutex poisoned"); + for action in state.pending_chat_actions.drain(..) { + let _ = control.publish_chat_action(action); + } + state.control = Some(control); + product_runtime + }); + let (bridge, endpoint) = WsBridge::start(bind_port, runtime_factory, logger)?; + *guard = Some(bridge); + Ok(endpoint) + } + + /// Stop the active bridge while leaving the execution reusable. + pub fn stop_ws_bridge(&self) { + if let Some(mut bridge) = self + .bridge + .lock() + .expect("native product bridge mutex poisoned") + .take() + { + bridge.stop(); + } + *self + .product_control + .lock() + .expect("native product control mutex poisoned") = NativeProductControlState::default(); + } +} + +impl Drop for NativeProductExecution { + fn drop(&mut self) { + self.close(); + } +} + +/// Legacy single-execution UniFFI object retained for existing embedders. +/// New native integrations should use [`NativeTrUApiHostRuntime`] and +/// [`NativeProductExecution`]. #[derive(uniffi::Object)] pub struct NativeTrUApiCore { runtime: Arc, product: ProductContext, events: Arc, #[cfg(feature = "ws-bridge")] + spawner: Spawner, + #[cfg(feature = "ws-bridge")] callbacks: Arc, #[cfg(feature = "ws-bridge")] bridge: std::sync::Mutex>, + #[cfg(feature = "ws-bridge")] + product_control: Arc>, } #[uniffi::export] @@ -610,6 +1308,88 @@ impl NativeTrUApiCore { pub fn notify_chain_closed(&self, connection_id: u32) { self.events.notify_chain_closed(connection_id); } + + /// Push a complete replacement of the current native Chat room list. + pub fn notify_chat_rooms_changed(&self, rooms: Vec) { + self.events.notify_chat_rooms_changed(rooms); + } + + /// Publish one native Chat action to the connected product worker. + pub fn publish_chat_action(&self, action: NativeChatAction) -> Result<(), NativeChatError> { + #[cfg(feature = "ws-bridge")] + { + if self.product.execution_kind != ProductExecutionKind::Chat + || !self.runtime.has_active_session() + { + return Err(NativeChatError::Denied); + } + if !self.runtime.supports_chat() { + return Err(NativeChatError::Unsupported); + } + + let action: v01::HostChatActionSubscribeItem = action.into(); + let mut state = self + .product_control + .lock() + .expect("native product control mutex poisoned"); + if let Some(control) = state.control.as_ref() { + match control.publish_chat_action(action.clone()) { + Ok(()) => return Ok(()), + Err(crate::ProductRuntimeError::Closed) => state.control = None, + Err(error) => return Err(error.into()), + } + } + + if state.pending_chat_actions.len() == NATIVE_CHAT_ACTION_BUFFER_CAPACITY { + return Err(NativeChatError::BufferFull); + } + state.pending_chat_actions.push_back(action); + Ok(()) + } + #[cfg(not(feature = "ws-bridge"))] + { + let _ = action; + Err(NativeChatError::NotConnected) + } + } + + /// Request typed native UI for one stored custom Chat message. + pub fn render_custom_message( + &self, + message_id: String, + message_type: String, + payload: Vec, + observer: Box, + ) -> Result, NativeChatError> { + #[cfg(feature = "ws-bridge")] + { + if self.product.execution_kind != ProductExecutionKind::Chat + || !self.runtime.has_active_session() + { + return Err(NativeChatError::Denied); + } + if !self.runtime.supports_chat() { + return Err(NativeChatError::Unsupported); + } + let control = self + .product_control + .lock() + .expect("native product control mutex poisoned") + .control + .clone() + .ok_or(NativeChatError::NotConnected)?; + let stream = control + .render_custom_message(message_id, message_type, payload) + .map_err(NativeChatError::from)?; + let observer: Arc = observer.into(); + Ok(observe_renderer(stream, observer, self.spawner.clone())) + } + #[cfg(not(feature = "ws-bridge"))] + { + let _ = (message_id, message_type, payload, observer); + Err(NativeChatError::NotConnected) + } + } } /// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`) for @@ -646,11 +1426,20 @@ fn native_core_from_platform_config( events: events.clone(), }); let spawner = native_thread_pool_spawner(&callbacks); - let runtime = Arc::new(SigningHostRuntime::new( - platform, - runtime_config.signing, - spawner, - )); + let runtime = if callbacks.chat_supported() { + Arc::new(SigningHostRuntime::new_with_chat( + platform.clone(), + platform, + runtime_config.signing, + spawner.clone(), + )) + } else { + Arc::new(SigningHostRuntime::new( + platform, + runtime_config.signing, + spawner.clone(), + )) + }; if let Some(secret) = runtime_config.local_session_secret { futures::executor::block_on(runtime.activate_local_session_with_identity( @@ -665,9 +1454,13 @@ fn native_core_from_platform_config( product: runtime_config.product, events, #[cfg(feature = "ws-bridge")] + spawner, + #[cfg(feature = "ws-bridge")] callbacks, #[cfg(feature = "ws-bridge")] bridge: std::sync::Mutex::new(None), + #[cfg(feature = "ws-bridge")] + product_control: Arc::new(Mutex::new(NativeProductControlState::default())), })) } @@ -689,7 +1482,19 @@ impl NativeTrUApiCore { }; let runtime = self.runtime.clone(); let product = self.product.clone(); - let runtime_factory = Arc::new(move |sink| runtime.product_runtime(product.clone(), sink)); + let product_control = self.product_control.clone(); + let runtime_factory = Arc::new(move |sink| { + let product_runtime = runtime.product_runtime(product.clone(), sink); + let control = product_runtime.control(); + let mut state = product_control + .lock() + .expect("native product control mutex poisoned"); + for action in state.pending_chat_actions.drain(..) { + let _ = control.publish_chat_action(action); + } + state.control = Some(control); + product_runtime + }); let (bridge, endpoint) = WsBridge::start(bind_port, runtime_factory, logger)?; *guard = Some(bridge); Ok(endpoint) @@ -700,6 +1505,10 @@ impl NativeTrUApiCore { if let Some(mut bridge) = self.bridge.lock().unwrap().take() { bridge.stop(); } + *self + .product_control + .lock() + .expect("native product control mutex poisoned") = NativeProductControlState::default(); } } @@ -763,6 +1572,7 @@ struct NativeEventBus { theme_changes: Mutex>>>, preimage_changes: Mutex>, chain_responses: Mutex>>, + chat_room_changes: Mutex>>, } struct PreimageSubscription { @@ -843,6 +1653,26 @@ impl NativeEventBus { .expect("native chain subscribers mutex poisoned") .remove(&connection_id); } + + fn subscribe_chat_rooms( + &self, + current: v01::HostChatListSubscribeItem, + ) -> BoxStream<'static, v01::HostChatListSubscribeItem> { + let (tx, rx) = mpsc::unbounded(); + self.chat_room_changes + .lock() + .expect("native Chat room subscribers mutex poisoned") + .push(tx); + stream::once(async move { current }).chain(rx).boxed() + } + + fn notify_chat_rooms_changed(&self, rooms: Vec) { + let item = native_chat_room_list(rooms); + self.chat_room_changes + .lock() + .expect("native Chat room subscribers mutex poisoned") + .retain(|tx| tx.unbounded_send(item.clone()).is_ok()); + } } #[async_trait] @@ -1128,6 +1958,79 @@ impl PreimageHost for CallbackPlatform { } } +#[async_trait] +impl truapi_platform::ChatPlatform for CallbackPlatform { + async fn create_room( + &self, + _product: &ProductContext, + request: v01::HostChatCreateRoomRequest, + ) -> Result { + self.callbacks + .chat_create_room(request.room_id, request.name, request.icon) + .map(|status| v01::HostChatCreateRoomResponse { + status: match status { + NativeChatRoomRegistrationStatus::New => v01::ChatRoomRegistrationStatus::New, + NativeChatRoomRegistrationStatus::Exists => { + v01::ChatRoomRegistrationStatus::Exists + } + }, + }) + .map_err(|error| v01::HostChatCreateRoomError::Unknown { + reason: error.to_string(), + }) + } + + async fn post_message( + &self, + _product: &ProductContext, + request: v01::HostChatPostMessageRequest, + ) -> Result { + let message_id = match request.payload { + v01::ChatMessageContent::Text { text } => { + self.callbacks.chat_post_text_message(request.room_id, text) + } + v01::ChatMessageContent::Custom(custom) => self.callbacks.chat_post_custom_message( + request.room_id, + custom.message_type, + custom.payload, + ), + _ => { + return Err(v01::HostChatPostMessageError::Unknown { + reason: "native Chat adapter supports text and custom messages".to_string(), + }); + } + } + .map_err(|error| v01::HostChatPostMessageError::Unknown { + reason: error.to_string(), + })?; + Ok(v01::HostChatPostMessageResponse { message_id }) + } + + fn subscribe_rooms( + &self, + _product: &ProductContext, + ) -> BoxStream<'static, v01::HostChatListSubscribeItem> { + let current = native_chat_room_list(self.callbacks.chat_list_rooms().unwrap_or_default()); + self.events.subscribe_chat_rooms(current) + } +} + +fn native_chat_room_list(rooms: Vec) -> v01::HostChatListSubscribeItem { + v01::HostChatListSubscribeItem { + rooms: rooms + .into_iter() + .map(|room| v01::ChatRoom { + room_id: room.room_id, + participating_as: if room.is_host { + v01::ChatRoomParticipation::RoomHost + } else { + v01::ChatRoomParticipation::Bot + }, + }) + .collect(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1135,6 +2038,10 @@ mod tests { type PreimageFixtureEntries = Vec<(Vec, Option>)>; struct EventCallbacks { + chat_supported: bool, + chat_room_status: Mutex, + chat_created_rooms: Mutex>, + chat_posted_text: Mutex>, theme: Mutex, preimages: Mutex, auth_states: Mutex>, @@ -1147,6 +2054,10 @@ mod tests { impl EventCallbacks { fn new() -> Self { Self { + chat_supported: false, + chat_room_status: Mutex::new(NativeChatRoomRegistrationStatus::New), + chat_created_rooms: Mutex::new(Vec::new()), + chat_posted_text: Mutex::new(Vec::new()), theme: Mutex::new(HostTheme::Light), preimages: Mutex::new(Vec::new()), auth_states: Mutex::new(Vec::new()), @@ -1156,6 +2067,13 @@ mod tests { chain_closes: Mutex::new(Vec::new()), } } + + fn with_chat() -> Self { + Self { + chat_supported: true, + ..Self::new() + } + } } impl HostCallbacks for EventCallbacks { @@ -1242,6 +2160,38 @@ mod tests { fn local_storage_clear(&self, _key: String) -> Result<(), HostStorageError> { Ok(()) } + + fn chat_supported(&self) -> bool { + self.chat_supported + } + + fn chat_create_room( + &self, + room_id: String, + _name: String, + _icon: String, + ) -> Result { + self.chat_created_rooms + .lock() + .expect("created rooms mutex poisoned") + .push(room_id); + Ok(*self + .chat_room_status + .lock() + .expect("room status mutex poisoned")) + } + + fn chat_post_text_message( + &self, + room_id: String, + text: String, + ) -> Result { + self.chat_posted_text + .lock() + .expect("posted text mutex poisoned") + .push((room_id, text)); + Ok("message-id".to_string()) + } } fn event_platform() -> (Arc, Arc, CallbackPlatform) { @@ -1257,6 +2207,7 @@ mod tests { fn native_runtime_config(product_id: &str) -> NativeRuntimeConfig { NativeRuntimeConfig { product_id: product_id.to_string(), + execution_kind: NativeProductExecutionKind::App, host_name: "Polkadot Web".to_string(), host_icon: Some("https://example.invalid/dotli.png".to_string()), host_version: None, @@ -1270,6 +2221,106 @@ mod tests { } } + fn native_host_runtime_config() -> NativeHostRuntimeConfig { + NativeHostRuntimeConfig { + host_name: "Polkadot Web".to_string(), + host_icon: Some("https://example.invalid/dotli.png".to_string()), + host_version: None, + platform_type: None, + platform_version: None, + people_chain_genesis_hash: vec![0xa2; 32], + bulletin_chain_genesis_hash: vec![0xbb; 32], + local_session_secret: Some(vec![7; 32]), + local_session_lite_username: Some("alice".to_string()), + } + } + + fn native_execution_config( + product_id: &str, + execution_kind: NativeProductExecutionKind, + ) -> NativeProductExecutionConfig { + NativeProductExecutionConfig { + product_id: product_id.to_string(), + execution_kind, + } + } + + #[test] + fn process_runtime_shares_authority_and_replaces_one_chat_execution_per_product() { + let host = NativeTrUApiHostRuntime::with_runtime_config( + Box::new(EventCallbacks::new()), + native_host_runtime_config(), + ) + .expect("host runtime config should be valid"); + let app = host + .open_product_execution( + Box::new(EventCallbacks::new()), + native_execution_config("shared.dot", NativeProductExecutionKind::App), + ) + .expect("App execution should open"); + let chat = host + .open_product_execution( + Box::new(EventCallbacks::with_chat()), + native_execution_config("shared.dot", NativeProductExecutionKind::Chat), + ) + .expect("Chat execution should open"); + + assert!(Arc::ptr_eq(&app.runtime, &chat.runtime)); + assert!(matches!( + app.publish_chat_action(NativeChatAction::MessagePostedText { + room_id: "room".to_string(), + peer: "native".to_string(), + text: "denied".to_string(), + }), + Err(NativeChatError::Denied) + )); + chat.publish_chat_action(NativeChatAction::MessagePostedText { + room_id: "room".to_string(), + peer: "native".to_string(), + text: "buffered".to_string(), + }) + .expect("Chat action should buffer before connection"); + + let replacement = host + .open_product_execution( + Box::new(EventCallbacks::with_chat()), + native_execution_config("shared.dot", NativeProductExecutionKind::Chat), + ) + .expect("replacement Chat execution should open"); + assert!(matches!( + chat.publish_chat_action(NativeChatAction::MessagePostedText { + room_id: "room".to_string(), + peer: "native".to_string(), + text: "closed".to_string(), + }), + Err(NativeChatError::Closed) + )); + replacement + .publish_chat_action(NativeChatAction::MessagePostedText { + room_id: "room".to_string(), + peer: "native".to_string(), + text: "fresh".to_string(), + }) + .expect("replacement execution has a fresh buffer"); + } + + #[test] + fn native_chat_entrypoint_is_unsupported_without_an_adapter() { + let mut config = native_runtime_config("chat-product.dot"); + config.execution_kind = NativeProductExecutionKind::Chat; + config.local_session_secret = Some(vec![7; 32]); + let core = NativeTrUApiCore::with_runtime_config(Box::new(EventCallbacks::new()), config) + .expect("runtime config should be valid"); + + let result = core.publish_chat_action(NativeChatAction::MessagePostedText { + room_id: "room".to_string(), + peer: "native".to_string(), + text: "hello".to_string(), + }); + + assert!(matches!(result, Err(NativeChatError::Unsupported))); + } + #[test] fn native_auth_presenter_forwards_states_across_the_ffi_mirror() { let (callbacks, _events, platform) = event_platform(); @@ -1343,6 +2394,116 @@ mod tests { assert_eq!(second.unwrap(), Some(vec![4, 5, 6])); } + #[test] + fn native_chat_room_subscription_emits_current_then_notified_replacement() { + let (_callbacks, events, platform) = event_platform(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) + .unwrap(); + let mut stream = truapi_platform::ChatPlatform::subscribe_rooms(&platform, &product); + + let first = futures::executor::block_on(stream.next()).unwrap(); + events.notify_chat_rooms_changed(vec![NativeChatRoom { + room_id: "support".to_string(), + is_host: false, + }]); + let second = futures::executor::block_on(stream.next()).unwrap(); + + assert!(first.rooms.is_empty()); + assert_eq!(second.rooms.len(), 1); + assert_eq!(second.rooms[0].room_id, "support"); + assert_eq!( + second.rooms[0].participating_as, + v01::ChatRoomParticipation::Bot + ); + } + + #[test] + fn native_chat_adapter_preserves_room_status_and_message_room() { + let callbacks = Arc::new(EventCallbacks::with_chat()); + let platform = CallbackPlatform { + callbacks: callbacks.clone(), + events: Arc::new(NativeEventBus::default()), + }; + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) + .unwrap(); + let request = v01::HostChatCreateRoomRequest { + room_id: "support".to_string(), + name: "Support".to_string(), + icon: String::new(), + }; + + let created = futures::executor::block_on(truapi_platform::ChatPlatform::create_room( + &platform, + &product, + request.clone(), + )) + .unwrap(); + *callbacks + .chat_room_status + .lock() + .expect("room status mutex poisoned") = NativeChatRoomRegistrationStatus::Exists; + let existing = futures::executor::block_on(truapi_platform::ChatPlatform::create_room( + &platform, &product, request, + )) + .unwrap(); + let posted = futures::executor::block_on(truapi_platform::ChatPlatform::post_message( + &platform, + &product, + v01::HostChatPostMessageRequest { + room_id: "second-room".to_string(), + payload: v01::ChatMessageContent::Text { + text: "Echo: hello".to_string(), + }, + }, + )) + .unwrap(); + + assert_eq!(created.status, v01::ChatRoomRegistrationStatus::New); + assert_eq!(existing.status, v01::ChatRoomRegistrationStatus::Exists); + assert_eq!(posted.message_id, "message-id"); + assert_eq!( + callbacks + .chat_created_rooms + .lock() + .expect("created rooms mutex poisoned") + .as_slice(), + &["support", "support"] + ); + assert_eq!( + callbacks + .chat_posted_text + .lock() + .expect("posted text mutex poisoned") + .as_slice(), + &[("second-room".to_string(), "Echo: hello".to_string())] + ); + } + + #[test] + fn native_widget_action_preserves_message_and_action_identifiers() { + let action: v01::HostChatActionSubscribeItem = NativeChatAction::ActionTriggered { + room_id: "room".to_string(), + peer: "native".to_string(), + message_id: "message-42".to_string(), + action_id: "custom_renderer_action_:r3:".to_string(), + payload: Some(vec![1, 2, 3]), + } + .into(); + + assert_eq!(action.room_id, "room"); + assert_eq!(action.peer, "native"); + assert_eq!( + action.payload, + v01::ChatActionPayload::ActionTriggered(v01::ActionTrigger { + message_id: "message-42".to_string(), + action_id: "custom_renderer_action_:r3:".to_string(), + payload: Some(vec![1, 2, 3]), + }) + ); + } + #[test] fn native_chain_provider_forwards_send_response_and_close() { let (callbacks, events, platform) = event_platform(); diff --git a/rust/crates/truapi-server/src/native_renderer.rs b/rust/crates/truapi-server/src/native_renderer.rs new file mode 100644 index 00000000..4e254fae --- /dev/null +++ b/rust/crates/truapi-server/src/native_renderer.rs @@ -0,0 +1,755 @@ +//! Typed native projection of the recursive custom-renderer tree. + +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use futures::future::{AbortHandle, Abortable}; +use truapi::{Subscription, v01}; + +use crate::subscription::Spawner; + +/// Native custom-renderer node discriminator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererNodeKind { + /// Empty node. + Nil, + /// Raw text child. + String, + /// Generic container. + Box, + /// Vertical layout. + Column, + /// Horizontal layout. + Row, + /// Flexible space. + Spacer, + /// Styled text. + Text, + /// Interactive button. + Button, + /// Editable text field. + TextField, +} + +/// Native semantic color token. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererColorToken { + /// Primary foreground. + FgPrimary, + /// Secondary foreground. + FgSecondary, + /// Tertiary foreground. + FgTertiary, + /// Main surface background. + BgSurfaceMain, + /// Container surface background. + BgSurfaceContainer, + /// Nested surface background. + BgSurfaceNested, + /// Success foreground. + FgSuccess, + /// Error foreground. + FgError, + /// Warning foreground. + FgWarning, +} + +/// Native typography token. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererTypographyStyle { + /// Large headline. + HeadlineLarge, + /// Medium regular title. + TitleMediumRegular, + /// Large regular body. + BodyLargeRegular, + /// Medium regular body. + BodyMediumRegular, + /// Small regular body. + BodySmallRegular, +} + +/// Native button style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererButtonVariant { + /// Primary button. + Primary, + /// Secondary button. + Secondary, + /// Text-only button. + Text, +} + +/// Native two-dimensional alignment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererContentAlignment { + /// Top-start. + TopStart, + /// Top-center. + TopCenter, + /// Top-end. + TopEnd, + /// Center-start. + CenterStart, + /// Centered. + Center, + /// Center-end. + CenterEnd, + /// Bottom-start. + BottomStart, + /// Bottom-center. + BottomCenter, + /// Bottom-end. + BottomEnd, +} + +/// Native horizontal alignment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererHorizontalAlignment { + /// Start edge. + Start, + /// Center. + Center, + /// End edge. + End, +} + +/// Native vertical alignment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererVerticalAlignment { + /// Top edge. + Top, + /// Center. + Center, + /// Bottom edge. + Bottom, +} + +/// Native layout arrangement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererArrangement { + /// Pack at the start. + Start, + /// Pack at the end. + End, + /// Pack at the center. + Center, + /// Space between children. + SpaceBetween, + /// Space around children. + SpaceAround, + /// Equal space between and around children. + SpaceEvenly, +} + +/// Native renderer dimensions in logical pixels. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererDimensions { + /// Top dimension. + pub top: u64, + /// End dimension. + pub end: u64, + /// Optional bottom dimension. + pub bottom: Option, + /// Optional start dimension. + pub start: Option, +} + +/// Native renderer shape. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererShape { + /// Rounded rectangle. + Rounded { + /// Corner radius. + radius: u64, + }, + /// Circle. + Circle, +} + +/// Native renderer background. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererBackground { + /// Background color. + pub color: NativeCustomRendererColorToken, + /// Optional background shape. + pub shape: Option, +} + +/// Native renderer border. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererBorderStyle { + /// Border width. + pub width: u64, + /// Border color. + pub color: NativeCustomRendererColorToken, + /// Optional border shape. + pub shape: Option, +} + +/// Typed native renderer modifier. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum NativeCustomRendererModifier { + /// Outer spacing. + Margin { + /// Spacing dimensions. + dimensions: NativeCustomRendererDimensions, + }, + /// Inner spacing. + Padding { + /// Spacing dimensions. + dimensions: NativeCustomRendererDimensions, + }, + /// Background fill. + Background { + /// Background style. + background: NativeCustomRendererBackground, + }, + /// Border. + Border { + /// Border style. + border: NativeCustomRendererBorderStyle, + }, + /// Fixed height. + Height { + /// Height in logical pixels. + height: u64, + }, + /// Fixed width. + Width { + /// Width in logical pixels. + width: u64, + }, + /// Minimum width. + MinWidth { + /// Width in logical pixels. + width: u64, + }, + /// Minimum height. + MinHeight { + /// Height in logical pixels. + height: u64, + }, + /// Fill available width. + FillWidth { + /// Whether filling is enabled. + enabled: bool, + }, + /// Fill available height. + FillHeight { + /// Whether filling is enabled. + enabled: bool, + }, +} + +/// Native properties for a box node. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererBoxProps { + /// Optional content alignment. + pub content_alignment: Option, +} + +/// Native properties for a column node. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererColumnProps { + /// Optional horizontal alignment. + pub horizontal_alignment: Option, + /// Optional vertical arrangement. + pub vertical_arrangement: Option, +} + +/// Native properties for a row node. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererRowProps { + /// Optional vertical alignment. + pub vertical_alignment: Option, + /// Optional horizontal arrangement. + pub horizontal_arrangement: Option, +} + +/// Native properties for a text node. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererTextProps { + /// Optional typography token. + pub style: Option, + /// Optional color token. + pub color: Option, +} + +/// Native properties for a button node. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererButtonProps { + /// Button label. + pub text: String, + /// Optional button style. + pub variant: Option, + /// Optional enabled override. + pub enabled: Option, + /// Optional loading override. + pub loading: Option, + /// Optional action identifier. + pub click_action: Option, +} + +/// Native properties for a text-field node. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativeCustomRendererTextFieldProps { + /// Current text. + pub text: String, + /// Optional placeholder. + pub placeholder: Option, + /// Optional label. + pub label: Option, + /// Optional enabled override. + pub enabled: Option, + /// Optional value-change action identifier. + pub value_change_action: Option, +} + +/// Opaque recursive custom-renderer node exposed through typed accessors. +#[derive(Debug, uniffi::Object)] +pub struct NativeCustomRendererNode { + inner: v01::CustomRendererNode, +} + +#[uniffi::export] +impl NativeCustomRendererNode { + /// Return this node's discriminator. + pub fn kind(&self) -> NativeCustomRendererNodeKind { + match &self.inner { + v01::CustomRendererNode::Nil => NativeCustomRendererNodeKind::Nil, + v01::CustomRendererNode::String { .. } => NativeCustomRendererNodeKind::String, + v01::CustomRendererNode::Box(_) => NativeCustomRendererNodeKind::Box, + v01::CustomRendererNode::Column(_) => NativeCustomRendererNodeKind::Column, + v01::CustomRendererNode::Row(_) => NativeCustomRendererNodeKind::Row, + v01::CustomRendererNode::Spacer(_) => NativeCustomRendererNodeKind::Spacer, + v01::CustomRendererNode::Text(_) => NativeCustomRendererNodeKind::Text, + v01::CustomRendererNode::Button(_) => NativeCustomRendererNodeKind::Button, + v01::CustomRendererNode::TextField(_) => NativeCustomRendererNodeKind::TextField, + } + } + + /// Return raw text for a string node. + pub fn string_text(&self) -> Option { + match &self.inner { + v01::CustomRendererNode::String { text } => Some(text.clone()), + _ => None, + } + } + + /// Return this component node's modifiers. + pub fn modifiers(&self) -> Vec { + self.component_modifiers() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + /// Return this component node's recursive children. + pub fn children(&self) -> Vec> { + self.component_children() + .iter() + .cloned() + .map(|inner| Arc::new(Self { inner })) + .collect() + } + + /// Return box properties when this is a box node. + pub fn box_props(&self) -> Option { + match &self.inner { + v01::CustomRendererNode::Box(component) => Some(NativeCustomRendererBoxProps { + content_alignment: component.props.content_alignment.map(Into::into), + }), + _ => None, + } + } + + /// Return column properties when this is a column node. + pub fn column_props(&self) -> Option { + match &self.inner { + v01::CustomRendererNode::Column(component) => Some(NativeCustomRendererColumnProps { + horizontal_alignment: component.props.horizontal_alignment.map(Into::into), + vertical_arrangement: component.props.vertical_arrangement.map(Into::into), + }), + _ => None, + } + } + + /// Return row properties when this is a row node. + pub fn row_props(&self) -> Option { + match &self.inner { + v01::CustomRendererNode::Row(component) => Some(NativeCustomRendererRowProps { + vertical_alignment: component.props.vertical_alignment.map(Into::into), + horizontal_arrangement: component.props.horizontal_arrangement.map(Into::into), + }), + _ => None, + } + } + + /// Return text properties when this is a text node. + pub fn text_props(&self) -> Option { + match &self.inner { + v01::CustomRendererNode::Text(component) => Some(NativeCustomRendererTextProps { + style: component.props.style.map(Into::into), + color: component.props.color.map(Into::into), + }), + _ => None, + } + } + + /// Return button properties when this is a button node. + pub fn button_props(&self) -> Option { + match &self.inner { + v01::CustomRendererNode::Button(component) => Some(NativeCustomRendererButtonProps { + text: component.props.text.clone(), + variant: component.props.variant.map(Into::into), + enabled: component.props.enabled.0, + loading: component.props.loading.0, + click_action: component.props.click_action.clone(), + }), + _ => None, + } + } + + /// Return text-field properties when this is a text-field node. + pub fn text_field_props(&self) -> Option { + match &self.inner { + v01::CustomRendererNode::TextField(component) => { + Some(NativeCustomRendererTextFieldProps { + text: component.props.text.clone(), + placeholder: component.props.placeholder.clone(), + label: component.props.label.clone(), + enabled: component.props.enabled.0, + value_change_action: component.props.value_change_action.clone(), + }) + } + _ => None, + } + } +} + +impl NativeCustomRendererNode { + fn component_modifiers(&self) -> &[v01::Modifier] { + match &self.inner { + v01::CustomRendererNode::Box(component) => &component.modifiers, + v01::CustomRendererNode::Column(component) => &component.modifiers, + v01::CustomRendererNode::Row(component) => &component.modifiers, + v01::CustomRendererNode::Spacer(component) => &component.modifiers, + v01::CustomRendererNode::Text(component) => &component.modifiers, + v01::CustomRendererNode::Button(component) => &component.modifiers, + v01::CustomRendererNode::TextField(component) => &component.modifiers, + v01::CustomRendererNode::Nil | v01::CustomRendererNode::String { .. } => &[], + } + } + + fn component_children(&self) -> &[v01::CustomRendererNode] { + match &self.inner { + v01::CustomRendererNode::Box(component) => &component.children, + v01::CustomRendererNode::Column(component) => &component.children, + v01::CustomRendererNode::Row(component) => &component.children, + v01::CustomRendererNode::Spacer(component) => &component.children, + v01::CustomRendererNode::Text(component) => &component.children, + v01::CustomRendererNode::Button(component) => &component.children, + v01::CustomRendererNode::TextField(component) => &component.children, + v01::CustomRendererNode::Nil | v01::CustomRendererNode::String { .. } => &[], + } + } +} + +/// Observer implemented by a native host to receive renderer tree replacements. +#[uniffi::export(callback_interface)] +pub trait NativeCustomRendererObserver: Send + Sync { + /// Deliver a complete replacement tree. + fn on_update(&self, node: Arc); + + /// Report that the renderer stream ended. + fn on_complete(&self); +} + +/// Cancellable native observation of one custom-message render instance. +#[derive(uniffi::Object)] +pub struct NativeCustomRendererSubscription { + abort: Mutex>, +} + +#[uniffi::export] +impl NativeCustomRendererSubscription { + /// Stop delivering renderer updates to the native observer. + pub fn cancel(&self) { + if let Some(abort) = self + .abort + .lock() + .expect("native renderer subscription mutex poisoned") + .take() + { + abort.abort(); + } + } +} + +impl Drop for NativeCustomRendererSubscription { + fn drop(&mut self) { + if let Some(abort) = self + .abort + .get_mut() + .expect("native renderer subscription mutex poisoned") + .take() + { + abort.abort(); + } + } +} + +#[cfg_attr(not(feature = "ws-bridge"), allow(dead_code))] +pub(crate) fn observe_renderer( + mut stream: Subscription, + observer: Arc, + spawner: Spawner, +) -> Arc { + let (abort, registration) = AbortHandle::new_pair(); + (spawner)(Box::pin(async move { + let completed = Abortable::new( + async move { + while let Some(inner) = stream.next().await { + observer.on_update(Arc::new(NativeCustomRendererNode { inner })); + } + observer.on_complete(); + }, + registration, + ) + .await; + let _ = completed; + })); + Arc::new(NativeCustomRendererSubscription { + abort: Mutex::new(Some(abort)), + }) +} + +impl From for NativeCustomRendererDimensions { + fn from(value: v01::Dimensions) -> Self { + Self { + top: value.top.0, + end: value.end.0, + bottom: value.bottom.map(|size| size.0), + start: value.start.map(|size| size.0), + } + } +} + +impl From for NativeCustomRendererColorToken { + fn from(value: v01::ColorToken) -> Self { + match value { + v01::ColorToken::FgPrimary => Self::FgPrimary, + v01::ColorToken::FgSecondary => Self::FgSecondary, + v01::ColorToken::FgTertiary => Self::FgTertiary, + v01::ColorToken::BgSurfaceMain => Self::BgSurfaceMain, + v01::ColorToken::BgSurfaceContainer => Self::BgSurfaceContainer, + v01::ColorToken::BgSurfaceNested => Self::BgSurfaceNested, + v01::ColorToken::FgSuccess => Self::FgSuccess, + v01::ColorToken::FgError => Self::FgError, + v01::ColorToken::FgWarning => Self::FgWarning, + } + } +} + +impl From for NativeCustomRendererTypographyStyle { + fn from(value: v01::TypographyStyle) -> Self { + match value { + v01::TypographyStyle::HeadlineLarge => Self::HeadlineLarge, + v01::TypographyStyle::TitleMediumRegular => Self::TitleMediumRegular, + v01::TypographyStyle::BodyLargeRegular => Self::BodyLargeRegular, + v01::TypographyStyle::BodyMediumRegular => Self::BodyMediumRegular, + v01::TypographyStyle::BodySmallRegular => Self::BodySmallRegular, + } + } +} + +impl From for NativeCustomRendererButtonVariant { + fn from(value: v01::ButtonVariant) -> Self { + match value { + v01::ButtonVariant::Primary => Self::Primary, + v01::ButtonVariant::Secondary => Self::Secondary, + v01::ButtonVariant::Text => Self::Text, + } + } +} + +impl From for NativeCustomRendererContentAlignment { + fn from(value: v01::ContentAlignment) -> Self { + match value { + v01::ContentAlignment::TopStart => Self::TopStart, + v01::ContentAlignment::TopCenter => Self::TopCenter, + v01::ContentAlignment::TopEnd => Self::TopEnd, + v01::ContentAlignment::CenterStart => Self::CenterStart, + v01::ContentAlignment::Center => Self::Center, + v01::ContentAlignment::CenterEnd => Self::CenterEnd, + v01::ContentAlignment::BottomStart => Self::BottomStart, + v01::ContentAlignment::BottomCenter => Self::BottomCenter, + v01::ContentAlignment::BottomEnd => Self::BottomEnd, + } + } +} + +impl From for NativeCustomRendererHorizontalAlignment { + fn from(value: v01::HorizontalAlignment) -> Self { + match value { + v01::HorizontalAlignment::Start => Self::Start, + v01::HorizontalAlignment::Center => Self::Center, + v01::HorizontalAlignment::End => Self::End, + } + } +} + +impl From for NativeCustomRendererVerticalAlignment { + fn from(value: v01::VerticalAlignment) -> Self { + match value { + v01::VerticalAlignment::Top => Self::Top, + v01::VerticalAlignment::Center => Self::Center, + v01::VerticalAlignment::Bottom => Self::Bottom, + } + } +} + +impl From for NativeCustomRendererArrangement { + fn from(value: v01::Arrangement) -> Self { + match value { + v01::Arrangement::Start => Self::Start, + v01::Arrangement::End => Self::End, + v01::Arrangement::Center => Self::Center, + v01::Arrangement::SpaceBetween => Self::SpaceBetween, + v01::Arrangement::SpaceAround => Self::SpaceAround, + v01::Arrangement::SpaceEvenly => Self::SpaceEvenly, + } + } +} + +impl From for NativeCustomRendererShape { + fn from(value: v01::Shape) -> Self { + match value { + v01::Shape::Rounded { radius } => Self::Rounded { radius: radius.0 }, + v01::Shape::Circle => Self::Circle, + } + } +} + +impl From for NativeCustomRendererBackground { + fn from(value: v01::Background) -> Self { + Self { + color: value.color.into(), + shape: value.shape.map(Into::into), + } + } +} + +impl From for NativeCustomRendererBorderStyle { + fn from(value: v01::BorderStyle) -> Self { + Self { + width: value.width.0, + color: value.color.into(), + shape: value.shape.map(Into::into), + } + } +} + +impl From for NativeCustomRendererModifier { + fn from(value: v01::Modifier) -> Self { + match value { + v01::Modifier::Margin(dimensions) => Self::Margin { + dimensions: dimensions.into(), + }, + v01::Modifier::Padding(dimensions) => Self::Padding { + dimensions: dimensions.into(), + }, + v01::Modifier::Background(background) => Self::Background { + background: background.into(), + }, + v01::Modifier::Border(border) => Self::Border { + border: border.into(), + }, + v01::Modifier::Height { height } => Self::Height { height: height.0 }, + v01::Modifier::Width { width } => Self::Width { width: width.0 }, + v01::Modifier::MinWidth { width } => Self::MinWidth { width: width.0 }, + v01::Modifier::MinHeight { height } => Self::MinHeight { height: height.0 }, + v01::Modifier::FillWidth { enabled } => Self::FillWidth { enabled }, + v01::Modifier::FillHeight { enabled } => Self::FillHeight { enabled }, + } + } +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::{Compact, OptionBool}; + + use super::*; + + #[test] + fn projects_recursive_renderer_nodes_into_typed_native_values() { + let node = NativeCustomRendererNode { + inner: v01::CustomRendererNode::Column(v01::Component { + modifiers: vec![v01::Modifier::Padding(v01::Dimensions { + top: Compact(12), + end: Compact(8), + bottom: None, + start: Some(Compact(4)), + })], + props: v01::ColumnProps { + horizontal_alignment: Some(v01::HorizontalAlignment::Center), + vertical_arrangement: Some(v01::Arrangement::SpaceBetween), + }, + children: vec![ + v01::CustomRendererNode::String { + text: "Votes: 1".to_string(), + }, + v01::CustomRendererNode::Button(v01::Component { + modifiers: Vec::new(), + props: v01::ButtonProps { + text: "Vote".to_string(), + variant: Some(v01::ButtonVariant::Primary), + enabled: OptionBool(Some(true)), + loading: OptionBool(None), + click_action: Some("vote".to_string()), + }, + children: Vec::new(), + }), + ], + }), + }; + + assert_eq!(node.kind(), NativeCustomRendererNodeKind::Column); + assert_eq!( + node.column_props(), + Some(NativeCustomRendererColumnProps { + horizontal_alignment: Some(NativeCustomRendererHorizontalAlignment::Center), + vertical_arrangement: Some(NativeCustomRendererArrangement::SpaceBetween), + }) + ); + assert_eq!( + node.modifiers(), + vec![NativeCustomRendererModifier::Padding { + dimensions: NativeCustomRendererDimensions { + top: 12, + end: 8, + bottom: None, + start: Some(4), + }, + }] + ); + + let children = node.children(); + assert_eq!(children.len(), 2); + assert_eq!(children[0].kind(), NativeCustomRendererNodeKind::String); + assert_eq!(children[0].string_text().as_deref(), Some("Votes: 1")); + assert_eq!(children[1].kind(), NativeCustomRendererNodeKind::Button); + assert_eq!( + children[1].button_props(), + Some(NativeCustomRendererButtonProps { + text: "Vote".to_string(), + variant: Some(NativeCustomRendererButtonVariant::Primary), + enabled: Some(true), + loading: None, + click_action: Some("vote".to_string()), + }) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 50f31d01..87bc1102 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -13,6 +13,7 @@ pub(crate) mod auth_state; mod authority; /// In-core Bulletin preimage submission over the shared Subxt client. pub(crate) mod bulletin_rpc; +mod chat; mod identity; mod pairing_host; /// Role-neutral runtime services shared by product-facing runtimes. @@ -53,6 +54,7 @@ use crate::runtime::bulletin_rpc::BulletinSubmitError; #[cfg(test)] use crate::subscription::Spawner; pub(crate) use authority::{BulletinAllowanceKey, ProductAuthority}; +pub(crate) use chat::ChatConnection; #[cfg(test)] use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; @@ -103,6 +105,12 @@ use truapi::versioned::chain::{ RemoteChainTransactionStopError, RemoteChainTransactionStopRequest, RemoteChainTransactionStopResponse, }; +use truapi::versioned::chat::{ + HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, + HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, + HostChatPostMessageRequest, HostChatPostMessageResponse, + ProductChatCustomMessageRenderSubscribeItem, ProductChatCustomMessageRenderSubscribeRequest, +}; use truapi::versioned::entropy::{ HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, }; @@ -151,13 +159,13 @@ use truapi::versioned::system::{ use truapi::versioned::theme::HostThemeSubscribeItem; use truapi::{CallContext, CallError, CancellationReason, Subscription}; use truapi::{latest, v01}; -#[cfg(test)] use truapi_platform::Platform; use truapi_platform::{ AccountAccessReview, CreateTransactionReview, IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, - ProductContext, ProductStorageKey, ResourceAllocationReview, SessionUiInfo, SignPayloadReview, - SignRawReview, UserConfirmationReview, normalize_product_identifier, + ProductContext, ProductExecutionKind, ProductStorageKey, ResourceAllocationReview, + SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, + normalize_product_identifier, }; /// Error reason surfaced to products when a remote permission is not granted. @@ -290,11 +298,14 @@ fn authority_cancellation_error(cx: &CallContext, reason: CancellationReason) -> /// `truapi::api::*` trait set the generated dispatcher routes to. pub struct ProductRuntimeHost { services: Arc, + platform: Arc, + chat_platform: Option>, authority: Arc, product: ProductContext, /// Stable per-product-runtime id used to scope long-lived chain follow /// operation ids within one shared host runtime. core_instance: u64, + chat: Arc, } impl ProductRuntimeHost { @@ -303,16 +314,43 @@ impl ProductRuntimeHost { services: Arc, authority: Arc, product: ProductContext, + ) -> Self { + Self::from_services_with_platform( + services.clone(), + services.platform.clone(), + services.chat.clone(), + authority, + product, + ) + } + + /// Build a dispatcher target with connection-specific platform adapters + /// while retaining the host runtime's shared authority and infrastructure. + pub(crate) fn from_services_with_platform( + services: Arc, + platform: Arc, + chat_platform: Option>, + authority: Arc, + product: ProductContext, ) -> Self { let core_instance = services.next_core_instance(); + let chat = Arc::new(ChatConnection::new(services.spawner.clone())); Self { services, + platform, + chat_platform, authority, product, core_instance, + chat, } } + /// Trusted executable kind attached to this product connection. + pub(crate) fn execution_kind(&self) -> truapi_platform::ProductExecutionKind { + self.product.execution_kind + } + /// Test constructor building a standalone pairing-host runtime. #[cfg(test)] pub fn new

( @@ -394,11 +432,15 @@ impl ProductRuntimeHost { ); let pairing_host = PairingHost::new(services.clone(), host_config); let core_instance = services.next_core_instance(); + let chat = Arc::new(ChatConnection::new(services.spawner.clone())); let host = Self { services, + platform, + chat_platform: None, authority: pairing_host.clone(), product, core_instance, + chat, }; (host, pairing_host) } @@ -467,11 +509,8 @@ impl ProductRuntimeHost { request: PermissionAuthorizationRequest, ) -> Result { let product_id = self.product_id(); - let service = PermissionsService::new( - self.services.platform.as_ref(), - self.services.platform.as_ref(), - &product_id, - ); + let service = + PermissionsService::new(self.platform.as_ref(), self.platform.as_ref(), &product_id); service.authorization_status(&request).await } @@ -482,11 +521,8 @@ impl ProductRuntimeHost { requests: Vec, ) -> Result, v01::GenericError> { let product_id = self.product_id(); - let service = PermissionsService::new( - self.services.platform.as_ref(), - self.services.platform.as_ref(), - &product_id, - ); + let service = + PermissionsService::new(self.platform.as_ref(), self.platform.as_ref(), &product_id); service.authorization_statuses(&requests).await } @@ -499,11 +535,8 @@ impl ProductRuntimeHost { status: PermissionAuthorizationStatus, ) -> Result<(), v01::GenericError> { let product_id = self.product_id(); - let service = PermissionsService::new( - self.services.platform.as_ref(), - self.services.platform.as_ref(), - &product_id, - ); + let service = + PermissionsService::new(self.platform.as_ref(), self.platform.as_ref(), &product_id); service.set_authorization_status(&request, status).await } @@ -513,11 +546,8 @@ impl ProductRuntimeHost { permission: v01::RemotePermission, ) -> Result { let product_id = self.product_id(); - let service = PermissionsService::new( - self.services.platform.as_ref(), - self.services.platform.as_ref(), - &product_id, - ); + let service = + PermissionsService::new(self.platform.as_ref(), self.platform.as_ref(), &product_id); service .check_or_prompt_remote(v01::RemotePermissionRequest { permission }) .await @@ -552,11 +582,8 @@ impl ProductRuntimeHost { ) -> Result { let product_id = self.product_id(); let request = PermissionAuthorizationRequest::IdentityDisclosure; - let service = PermissionsService::new( - self.services.platform.as_ref(), - self.services.platform.as_ref(), - &product_id, - ); + let service = + PermissionsService::new(self.platform.as_ref(), self.platform.as_ref(), &product_id); let cached = service .authorization_status(&request) .await @@ -569,7 +596,6 @@ impl ProductRuntimeHost { // Fail the current disclosure request closed but keep authorization in // the ask/default state so the next request can prompt again. let confirmed = match self - .services .platform .confirm_user_action(UserConfirmationReview::IdentityDisclosure( IdentityDisclosureReview { @@ -623,7 +649,7 @@ impl ProductRuntimeHost { } async fn account_access_authorization( - services: &RuntimeServices, + platform: &dyn Platform, requesting_product_id: &str, target_product_id: &str, ) -> Result { @@ -634,11 +660,7 @@ async fn account_access_authorization( let request = PermissionAuthorizationRequest::AccountAccess { target_product_id: target_product_id.to_string(), }; - let service = PermissionsService::new( - services.platform.as_ref(), - services.platform.as_ref(), - requesting_product_id, - ); + let service = PermissionsService::new(platform, platform, requesting_product_id); let cached = service .authorization_status(&request) .await @@ -647,8 +669,7 @@ async fn account_access_authorization( return Ok(cached); } - let confirmed = services - .platform + let confirmed = platform .confirm_user_action(UserConfirmationReview::AccountAccess(AccountAccessReview { requesting_product_id: requesting_product_id.to_string(), target_product_id: target_product_id.to_string(), @@ -705,7 +726,7 @@ impl System for ProductRuntimeHost { request: HostFeatureSupportedRequest, ) -> Result> { let HostFeatureSupportedRequest::V1(inner) = request; - feature_supported(self.services.platform.as_ref(), inner) + feature_supported(self.platform.as_ref(), inner) .await .map(HostFeatureSupportedResponse::V1) .map_err(|err| CallError::Domain(HostFeatureSupportedError::V1(err))) @@ -733,8 +754,7 @@ impl System for ProductRuntimeHost { } }, }; - self.services - .platform + self.platform .navigate_to(resolved) .await .map(|()| HostNavigateToResponse::V1) @@ -756,11 +776,8 @@ impl Permissions for ProductRuntimeHost { ) -> Result> { let HostDevicePermissionRequest::V1(inner) = request; let product_id = self.product_id(); - let service = PermissionsService::new( - self.services.platform.as_ref(), - self.services.platform.as_ref(), - &product_id, - ); + let service = + PermissionsService::new(self.platform.as_ref(), self.platform.as_ref(), &product_id); match service.check_or_prompt_device(inner).await { Ok(decision) => Ok(HostDevicePermissionResponse::V1( v01::HostDevicePermissionResponse { @@ -781,11 +798,8 @@ impl Permissions for ProductRuntimeHost { ) -> Result> { let RemotePermissionRequest::V1(inner) = request; let product_id = self.product_id(); - let service = PermissionsService::new( - self.services.platform.as_ref(), - self.services.platform.as_ref(), - &product_id, - ); + let service = + PermissionsService::new(self.platform.as_ref(), self.platform.as_ref(), &product_id); match service.check_or_prompt_remote(inner).await { Ok(decision) => Ok(RemotePermissionResponse::V1( v01::RemotePermissionResponse { @@ -812,8 +826,7 @@ impl LocalStorage for ProductRuntimeHost { request: HostLocalStorageReadRequest, ) -> Result> { let HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key }) = request; - self.services - .platform + self.platform .read(self.product_storage_key(key)) .await .map(|value| { @@ -830,8 +843,7 @@ impl LocalStorage for ProductRuntimeHost { ) -> Result> { let HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { key, value }) = request; - self.services - .platform + self.platform .write(self.product_storage_key(key), value) .await .map(|()| HostLocalStorageWriteResponse::V1) @@ -845,8 +857,7 @@ impl LocalStorage for ProductRuntimeHost { request: HostLocalStorageClearRequest, ) -> Result> { let HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key }) = request; - self.services - .platform + self.platform .clear(self.product_storage_key(key)) .await .map(|()| HostLocalStorageClearResponse::V1) @@ -885,7 +896,7 @@ impl Account for ProductRuntimeHost { let product_id = self.product_id(); if product_account_id.dot_ns_identifier != product_id { match account_access_authorization( - &self.services, + self.platform.as_ref(), &product_id, &product_account_id.dot_ns_identifier, ) @@ -1172,7 +1183,6 @@ impl Signing for ProductRuntimeHost { ))); }; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::SignPayload( SignPayloadReview::Product(inner.clone()), @@ -1225,7 +1235,6 @@ impl Signing for ProductRuntimeHost { ))); }; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::SignRaw(SignRawReview::Product( inner.clone(), @@ -1278,7 +1287,6 @@ impl Signing for ProductRuntimeHost { ))); }; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::CreateTransaction( CreateTransactionReview::Product(inner.clone()), @@ -1340,7 +1348,6 @@ impl Signing for ProductRuntimeHost { )) .await?; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::SignPayload( SignPayloadReview::LegacyAccount(inner.clone()), @@ -1399,7 +1406,6 @@ impl Signing for ProductRuntimeHost { )) .await?; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::SignRaw( SignRawReview::LegacyAccount(inner.clone()), @@ -1467,7 +1473,6 @@ impl Signing for ProductRuntimeHost { )) .await?; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::CreateTransaction( CreateTransactionReview::LegacyAccount(inner.clone()), @@ -1751,13 +1756,131 @@ impl Chain for ProductRuntimeHost { // Payment and full account proof are explicitly out of current host parity, // but products should still observe the host's typed "not implemented" errors // rather than a generic transport failure. -// Chat and CoinPayment remain outside this milestone and keep their generated -// trait defaults until another host/product needs real implementations. +// CoinPayment remains outside this milestone and keeps its generated trait +// defaults until another host/product needs a real implementation. const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; -#[truapi::async_trait] -impl Chat for ProductRuntimeHost {} +impl ProductRuntimeHost { + fn chat_platform(&self) -> Result, CallError> { + if self.product.execution_kind != ProductExecutionKind::Chat { + return Err(CallError::Denied); + } + if self.authority.session_state().current().is_none() { + return Err(CallError::Denied); + } + self.chat_platform.clone().ok_or(CallError::Unsupported) + } + + fn chat_streams_available(&self) -> bool { + self.chat_platform::<()>().is_ok() + } + + fn require_native_chat(&self) -> Result<(), crate::host_core::ProductRuntimeError> { + if self.product.execution_kind != ProductExecutionKind::Chat { + return Err(crate::host_core::ProductRuntimeError::Denied); + } + if self.authority.session_state().current().is_none() { + return Err(crate::host_core::ProductRuntimeError::Denied); + } + if self.chat_platform.is_none() { + return Err(crate::host_core::ProductRuntimeError::Unsupported); + } + Ok(()) + } + + pub(crate) fn publish_chat_action( + &self, + action: v01::HostChatActionSubscribeItem, + ) -> Result<(), crate::host_core::ProductRuntimeError> { + self.require_native_chat()?; + self.chat + .publish_action(HostChatActionSubscribeItem::V1(action)) + } + + pub(crate) fn render_custom_message( + &self, + message_id: String, + message_type: String, + payload: Vec, + ) -> Result, crate::host_core::ProductRuntimeError> { + self.require_native_chat()?; + self.chat + .render_custom_message(message_id, message_type, payload) + } + + pub(crate) fn close_chat(&self) { + self.chat.close(); + } +} + +#[truapi_platform::async_trait] +impl Chat for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "chat.create_room"))] + async fn create_room( + &self, + _cx: &CallContext, + request: HostChatCreateRoomRequest, + ) -> Result> { + let platform = self.chat_platform()?; + let HostChatCreateRoomRequest::V1(request) = request; + platform + .create_room(&self.product, request) + .await + .map(HostChatCreateRoomResponse::V1) + .map_err(|error| CallError::Domain(HostChatCreateRoomError::V1(error))) + } + + #[instrument(skip_all, fields(runtime.method = "chat.list_subscribe"))] + async fn list_subscribe(&self, _cx: &CallContext) -> Subscription { + let Ok(platform) = self.chat_platform::<()>() else { + return Subscription::empty(); + }; + Subscription::new(Box::pin( + platform + .subscribe_rooms(&self.product) + .map(HostChatListSubscribeItem::V1), + )) + } + + #[instrument(skip_all, fields(runtime.method = "chat.post_message"))] + async fn post_message( + &self, + _cx: &CallContext, + request: HostChatPostMessageRequest, + ) -> Result> { + let platform = self.chat_platform()?; + let HostChatPostMessageRequest::V1(request) = request; + platform + .post_message(&self.product, request) + .await + .map(HostChatPostMessageResponse::V1) + .map_err(|error| CallError::Domain(HostChatPostMessageError::V1(error))) + } + + #[instrument(skip_all, fields(runtime.method = "chat.action_subscribe"))] + async fn action_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription { + if !self.chat_streams_available() { + return Subscription::empty(); + } + self.chat.subscribe_actions() + } + + #[instrument(skip_all, fields(runtime.method = "chat.custom_message_render_subscribe"))] + async fn custom_message_render_subscribe( + &self, + _cx: &CallContext, + requests: Subscription, + ) -> Subscription { + if !self.chat_streams_available() { + return Subscription::empty(); + } + self.chat.register_renderer(requests) + } +} #[truapi::async_trait] impl CoinPayment for ProductRuntimeHost {} #[truapi::async_trait] @@ -1838,7 +1961,6 @@ impl ResourceAllocation for ProductRuntimeHost { }; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::ResourceAllocation( ResourceAllocationReview { @@ -1952,7 +2074,6 @@ impl Preimage for ProductRuntimeHost { // miss (the wire item has no error channel and the product still needs // its initial current-value/miss emission). let stream = self - .services .platform .lookup_preimage(key.clone()) .filter_map(move |item| { @@ -2005,7 +2126,6 @@ impl Preimage for ProductRuntimeHost { ) .await?; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::PreimageSubmit( PreimageSubmitReview { @@ -2108,21 +2228,17 @@ fn bulletin_allowance_error_reason(err: AuthorityError) -> String { impl Theme for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "theme.subscribe"))] async fn subscribe(&self, _cx: &CallContext) -> Subscription { - let stream = self - .services - .platform - .subscribe_theme() - .filter_map(|item| async { - // TODO: preserve platform stream errors as terminal - // subscription interrupts once subscription items can carry - // in-stream failures. - item.ok().map(|variant| { - HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { - name: v01::ThemeName::Default, - variant, - }) + let stream = self.platform.subscribe_theme().filter_map(|item| async { + // TODO: preserve platform stream errors as terminal + // subscription interrupts once subscription items can carry + // in-stream failures. + item.ok().map(|variant| { + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant, }) - }); + }) + }); Subscription::new(Box::pin(stream)) } } @@ -2138,8 +2254,7 @@ impl Notifications for ProductRuntimeHost { request: HostPushNotificationRequest, ) -> Result> { let HostPushNotificationRequest::V1(inner) = request; - self.services - .platform + self.platform .push_notification(inner) .await .map(HostPushNotificationResponse::V1) @@ -2159,8 +2274,7 @@ impl Notifications for ProductRuntimeHost { { let HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { id }) = request; - self.services - .platform + self.platform .cancel_notification(id) .await .map(|()| HostPushNotificationCancelResponse::V1) diff --git a/rust/crates/truapi-server/src/runtime/chat.rs b/rust/crates/truapi-server/src/runtime/chat.rs new file mode 100644 index 00000000..f2a210ba --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/chat.rs @@ -0,0 +1,424 @@ +//! Connection-scoped Chat streams shared by product and native entrypoints. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use futures::channel::mpsc; +use truapi::versioned::chat::{ + HostChatActionSubscribeItem, ProductChatCustomMessageRenderSubscribeItem, + ProductChatCustomMessageRenderSubscribeRequest, +}; +use truapi::{Subscription, v01}; + +use crate::host_core::ProductRuntimeError; +use crate::subscription::Spawner; + +const ACTION_BUFFER_CAPACITY: usize = 64; + +struct RendererState { + generation: u64, + work: mpsc::UnboundedSender, + renders: HashMap>, +} + +struct State { + actions: Option>, + action_buffer: VecDeque, + renderer: Option, + next_renderer_generation: u64, + closed: bool, +} + +impl Default for State { + fn default() -> Self { + Self { + actions: None, + action_buffer: VecDeque::new(), + renderer: None, + next_renderer_generation: 1, + closed: false, + } + } +} + +/// Mutable Chat protocol state owned by one product connection. +pub(crate) struct ChatConnection { + state: Arc>, + spawner: Spawner, +} + +impl ChatConnection { + /// Create empty Chat state for one product connection. + pub(crate) fn new(spawner: Spawner) -> Self { + Self { + state: Arc::new(Mutex::new(State::default())), + spawner, + } + } + + /// Open the product's action subscription and drain buffered actions first. + pub(crate) fn subscribe_actions(&self) -> Subscription { + let (sender, receiver) = mpsc::unbounded(); + let mut state = self.state.lock().expect("chat state mutex poisoned"); + if state.closed { + return Subscription::empty(); + } + for item in state.action_buffer.drain(..) { + let _ = sender.unbounded_send(item); + } + state.actions = Some(sender); + Subscription::new(Box::pin(receiver)) + } + + /// Publish one native action, buffering it until the product subscribes. + pub(crate) fn publish_action( + &self, + action: HostChatActionSubscribeItem, + ) -> Result<(), ProductRuntimeError> { + let mut state = self.state.lock().expect("chat state mutex poisoned"); + if state.closed { + return Err(ProductRuntimeError::Closed); + } + if let Some(sender) = state.actions.as_ref() { + if sender.unbounded_send(action.clone()).is_ok() { + return Ok(()); + } + state.actions = None; + } + if state.action_buffer.len() == ACTION_BUFFER_CAPACITY { + return Err(ProductRuntimeError::BufferFull); + } + state.action_buffer.push_back(action); + Ok(()) + } + + /// Register the product's paired renderer streams for this connection. + pub(crate) fn register_renderer( + &self, + mut requests: Subscription, + ) -> Subscription { + let (work, receiver) = mpsc::unbounded(); + let generation = { + let mut state = self.state.lock().expect("chat state mutex poisoned"); + if state.closed { + return Subscription::empty(); + } + let generation = state.next_renderer_generation; + state.next_renderer_generation += 1; + state.renderer = Some(RendererState { + generation, + work, + renders: HashMap::new(), + }); + generation + }; + + let state = self.state.clone(); + (self.spawner)(Box::pin(async move { + while let Some(request) = requests.next().await { + let mut state = state.lock().expect("chat state mutex poisoned"); + let Some(renderer) = state + .renderer + .as_mut() + .filter(|renderer| renderer.generation == generation) + else { + break; + }; + match request { + ProductChatCustomMessageRenderSubscribeRequest::V1( + v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + message_id, + node, + }, + ) => { + if let Some(sender) = renderer.renders.get(&message_id) + && sender.unbounded_send(node).is_err() + { + renderer.renders.remove(&message_id); + } + } + ProductChatCustomMessageRenderSubscribeRequest::V1( + v01::ProductChatCustomMessageRenderSubscribeRequest::Failed { message_id }, + ) => { + renderer.renders.remove(&message_id); + } + } + } + let mut state = state.lock().expect("chat state mutex poisoned"); + if state + .renderer + .as_ref() + .is_some_and(|renderer| renderer.generation == generation) + { + state.renderer = None; + } + })); + + Subscription::new(Box::pin(receiver)) + } + + /// Send one render request and return its native replacement-tree stream. + pub(crate) fn render_custom_message( + &self, + message_id: String, + message_type: String, + payload: Vec, + ) -> Result, ProductRuntimeError> { + let (sender, receiver) = mpsc::unbounded(); + let mut state = self.state.lock().expect("chat state mutex poisoned"); + if state.closed { + return Err(ProductRuntimeError::Closed); + } + let renderer = state + .renderer + .as_mut() + .ok_or(ProductRuntimeError::Unsupported)?; + renderer.renders.insert(message_id.clone(), sender); + let item = ProductChatCustomMessageRenderSubscribeItem::V1( + v01::ProductChatCustomMessageRenderSubscribeItem { + message_id: message_id.clone(), + message_type, + payload, + }, + ); + if renderer.work.unbounded_send(item).is_err() { + renderer.renders.remove(&message_id); + return Err(ProductRuntimeError::Unsupported); + } + Ok(Subscription::new(Box::pin(receiver))) + } + + /// Close all connection-scoped Chat streams and discard buffered work. + pub(crate) fn close(&self) { + let mut state = self.state.lock().expect("chat state mutex poisoned"); + state.closed = true; + state.actions = None; + state.action_buffer.clear(); + state.renderer = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::executor::block_on; + use truapi::v01::{ChatActionPayload, ChatMessageContent, CustomRendererNode}; + + fn action(text: &str) -> HostChatActionSubscribeItem { + HostChatActionSubscribeItem::V1(v01::HostChatActionSubscribeItem { + room_id: "room".to_string(), + peer: "alice".to_string(), + payload: ChatActionPayload::MessagePosted(ChatMessageContent::Text { + text: text.to_string(), + }), + }) + } + + fn connection() -> ChatConnection { + ChatConnection::new(crate::subscription::thread_per_subscription_spawner()) + } + + #[test] + fn buffered_actions_are_drained_in_fifo_order() { + let connection = connection(); + connection.publish_action(action("first")).unwrap(); + connection.publish_action(action("second")).unwrap(); + + let mut actions = connection.subscribe_actions(); + assert_eq!(block_on(actions.next()), Some(action("first"))); + assert_eq!(block_on(actions.next()), Some(action("second"))); + } + + #[test] + fn full_startup_action_buffer_is_reported() { + let connection = connection(); + for index in 0..ACTION_BUFFER_CAPACITY { + connection + .publish_action(action(&index.to_string())) + .unwrap(); + } + + assert!(matches!( + connection.publish_action(action("overflow")), + Err(ProductRuntimeError::BufferFull) + )); + } + + #[test] + fn closing_discards_buffered_actions() { + let connection = connection(); + connection.publish_action(action("discard me")).unwrap(); + connection.close(); + + let mut actions = connection.subscribe_actions(); + assert_eq!(block_on(actions.next()), None); + assert!(matches!( + connection.publish_action(action("too late")), + Err(ProductRuntimeError::Closed) + )); + } + + #[test] + fn renderer_updates_are_routed_by_message_id() { + let connection = connection(); + let (requests_tx, requests_rx) = mpsc::unbounded(); + let mut work = connection.register_renderer(Subscription::new(Box::pin(requests_rx))); + let mut first = connection + .render_custom_message("one".into(), "vote".into(), vec![1]) + .unwrap(); + let mut second = connection + .render_custom_message("two".into(), "balance".into(), vec![2]) + .unwrap(); + + assert_eq!( + block_on(work.next()), + Some(ProductChatCustomMessageRenderSubscribeItem::V1( + v01::ProductChatCustomMessageRenderSubscribeItem { + message_id: "one".into(), + message_type: "vote".into(), + payload: vec![1], + } + )) + ); + assert_eq!( + block_on(work.next()), + Some(ProductChatCustomMessageRenderSubscribeItem::V1( + v01::ProductChatCustomMessageRenderSubscribeItem { + message_id: "two".into(), + message_type: "balance".into(), + payload: vec![2], + } + )) + ); + + let node = CustomRendererNode::String { + text: "second".into(), + }; + requests_tx + .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( + v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + message_id: "two".into(), + node: node.clone(), + }, + )) + .unwrap(); + + assert_eq!(block_on(second.next()), Some(node)); + + requests_tx + .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( + v01::ProductChatCustomMessageRenderSubscribeRequest::Failed { + message_id: "one".into(), + }, + )) + .unwrap(); + assert_eq!(block_on(first.next()), None); + } + + #[test] + fn renderer_accepts_multiple_replacements_for_one_message() { + let connection = connection(); + let (requests_tx, requests_rx) = mpsc::unbounded(); + let mut work = connection.register_renderer(Subscription::new(Box::pin(requests_rx))); + let mut render = connection + .render_custom_message("one".into(), "counter".into(), vec![]) + .unwrap(); + assert!(block_on(work.next()).is_some()); + + for text in ["first", "second"] { + requests_tx + .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( + v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + message_id: "one".into(), + node: CustomRendererNode::String { text: text.into() }, + }, + )) + .unwrap(); + } + + assert_eq!( + block_on(render.next()), + Some(CustomRendererNode::String { + text: "first".into() + }) + ); + assert_eq!( + block_on(render.next()), + Some(CustomRendererNode::String { + text: "second".into() + }) + ); + } + + #[test] + fn replacing_renderer_closes_old_work_and_render_instances() { + let connection = connection(); + let (_first_requests_tx, first_requests_rx) = mpsc::unbounded(); + let mut first_work = + connection.register_renderer(Subscription::new(Box::pin(first_requests_rx))); + let mut first_render = connection + .render_custom_message("old".into(), "vote".into(), vec![]) + .unwrap(); + assert!(block_on(first_work.next()).is_some()); + + let (_second_requests_tx, second_requests_rx) = mpsc::unbounded(); + let mut second_work = + connection.register_renderer(Subscription::new(Box::pin(second_requests_rx))); + + assert_eq!(block_on(first_work.next()), None); + assert_eq!(block_on(first_render.next()), None); + + let mut second_render = connection + .render_custom_message("new".into(), "vote".into(), vec![]) + .unwrap(); + assert!(block_on(second_work.next()).is_some()); + connection.close(); + assert_eq!(block_on(second_work.next()), None); + assert_eq!(block_on(second_render.next()), None); + } + + #[test] + fn separate_connections_cannot_observe_each_others_actions_or_renders() { + let first = connection(); + let second = connection(); + let mut first_actions = first.subscribe_actions(); + let mut second_actions = second.subscribe_actions(); + + first.publish_action(action("first only")).unwrap(); + second.publish_action(action("second only")).unwrap(); + assert_eq!(block_on(first_actions.next()), Some(action("first only"))); + assert_eq!(block_on(second_actions.next()), Some(action("second only"))); + + let (first_requests_tx, first_requests_rx) = mpsc::unbounded(); + let mut first_work = + first.register_renderer(Subscription::new(Box::pin(first_requests_rx))); + let (_second_requests_tx, second_requests_rx) = mpsc::unbounded(); + let mut second_work = + second.register_renderer(Subscription::new(Box::pin(second_requests_rx))); + let mut first_render = first + .render_custom_message("same-id".into(), "first".into(), vec![]) + .unwrap(); + let mut second_render = second + .render_custom_message("same-id".into(), "second".into(), vec![]) + .unwrap(); + assert!(block_on(first_work.next()).is_some()); + assert!(block_on(second_work.next()).is_some()); + + let node = CustomRendererNode::String { + text: "first product".into(), + }; + first_requests_tx + .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( + v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + message_id: "same-id".into(), + node: node.clone(), + }, + )) + .unwrap(); + assert_eq!(block_on(first_render.next()), Some(node)); + + second.close(); + assert_eq!(block_on(second_render.next()), None); + } +} diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 7c6fb3c4..10768988 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -14,7 +14,7 @@ use crate::runtime::statement_store_rpc::StatementStoreRpc; use crate::subscription::Spawner; use async_trait::async_trait; use truapi::latest; -use truapi_platform::{JsonRpcConnection, Platform}; +use truapi_platform::{ChatPlatform, JsonRpcConnection, Platform}; /// Upper bound on the in-core preimage cache. The cache is a bridge until /// content propagates to the lookup backend, not a store, so it stays small. @@ -28,6 +28,8 @@ const STATEMENT_CACHE_MAX_ENTRIES: usize = 64; pub(crate) struct RuntimeServices { /// Host platform backing all syscalls. pub(crate) platform: Arc, + /// Optional native Chat adapter shared by Chat product connections. + pub(crate) chat: Option>, /// Shared chainHead-v1 runtime behind the Chain surface. pub(crate) chain: ChainRuntime, /// People-chain statement store RPC client. @@ -54,6 +56,23 @@ impl RuntimeServices { people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, + ) -> Arc { + Self::new_with_chat( + platform, + None, + people_chain_genesis_hash, + bulletin_chain_genesis_hash, + spawner, + ) + } + + /// Build role-neutral runtime services with an optional native Chat adapter. + pub(crate) fn new_with_chat( + platform: Arc, + chat: Option>, + people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], + spawner: Spawner, ) -> Arc { let chain_provider = Arc::new(HostChainProvider { platform: platform.clone(), @@ -64,6 +83,7 @@ impl RuntimeServices { let bulletin = BulletinRpc::new(chain.clone(), bulletin_chain_genesis_hash); Arc::new(Self { platform, + chat, chain, statement_store, bulletin, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 42d06090..7085b814 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -358,7 +358,7 @@ impl ProductAuthority for SigningHost { ) -> Result { require_current_session(&self.session_state, session)?; match super::account_access_authorization( - &self.services, + self.services.platform.as_ref(), &request.calling_product_id, &request.context.product_id, ) diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs index cd026bd0..502616e2 100644 --- a/rust/crates/truapi-server/src/subscription.rs +++ b/rust/crates/truapi-server/src/subscription.rs @@ -10,9 +10,10 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use futures::StreamExt; +use futures::channel::mpsc; use futures::future::{BoxFuture, Either, select}; use futures::stream::BoxStream; -use parity_scale_codec::Encode; +use parity_scale_codec::{Decode, Encode}; use crate::frame::{Payload, ProtocolMessage}; use crate::transport::Transport; @@ -49,6 +50,9 @@ pub enum SubscriptionOutput { /// Boxed stream of [`SubscriptionOutput`] consumed by the dispatcher. pub type SubscriptionStream = BoxStream<'static, SubscriptionOutput>; +/// Raw product-to-host values carried by a paired subscription. +pub type SubscriptionRequestStream = BoxStream<'static, Vec>; + /// Wrap a host-side stream of typed items into the SCALE-encoded /// [`SubscriptionStream`] that the dispatcher delivers to the transport. /// @@ -64,6 +68,23 @@ where Box::pin(stream.map(|item| SubscriptionOutput::Item(item.encode()))) } +/// Decode raw paired-stream request values into the typed TrUAPI stream. +/// The first malformed value terminates this stream. The paired response +/// stream then completes through its handler, without affecting sibling +/// protocol streams on the same connection. +pub fn subscription_request_stream( + stream: SubscriptionRequestStream, +) -> truapi::Subscription +where + Item: Decode + Send + 'static, +{ + let decoded = stream.scan( + (), + |_, bytes| async move { Item::decode(&mut &bytes[..]).ok() }, + ); + truapi::Subscription::new(Box::pin(decoded)) +} + /// Generation-stamped slot tracking the lifecycle of one subscription id. /// `request_id` is client-controlled and may be reused or raced against a /// `_stop`, so each reservation carries a monotonic generation and only the @@ -86,9 +107,12 @@ pub struct ReservationToken { generation: u64, } +type RequestSenderMap = HashMap>)>; + /// Manages active subscriptions on the server side. pub struct SubscriptionManager { active: Arc>>, + request_senders: Arc>, next_generation: Arc, spawner: Spawner, } @@ -98,6 +122,7 @@ impl SubscriptionManager { pub fn new(spawner: Spawner) -> Self { Self { active: Arc::new(Mutex::new(HashMap::new())), + request_senders: Arc::new(Mutex::new(HashMap::new())), next_generation: Arc::new(AtomicU64::new(0)), spawner, } @@ -109,6 +134,7 @@ impl SubscriptionManager { /// [`activate`](Self::activate) flips the reservation to cancelled. pub fn reserve(&self, request_id: String) -> ReservationToken { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + self.request_senders.lock().unwrap().remove(&request_id); let mut active = self.active.lock().unwrap(); if let Some(Slot::Live { cancel, .. }) = active.insert( request_id.clone(), @@ -125,6 +151,21 @@ impl SubscriptionManager { } } + /// Reserve a paired subscription and return its product-to-host request stream. + pub fn reserve_pair( + &self, + request_id: String, + receive_id: u8, + ) -> (ReservationToken, SubscriptionRequestStream) { + let token = self.reserve(request_id.clone()); + let (sender, receiver) = mpsc::unbounded(); + self.request_senders + .lock() + .unwrap() + .insert(request_id, (token.generation, receive_id, sender)); + (token, Box::pin(receiver)) + } + /// Drop a reservation whose `_start` handler failed before producing a /// stream. No-op if the slot was superseded by a newer reservation. pub fn cancel_reservation(&self, token: ReservationToken) { @@ -136,6 +177,10 @@ impl SubscriptionManager { if owned { active.remove(&token.request_id); } + drop(active); + if owned { + self.remove_request_sender(&token.request_id, token.generation); + } } /// Activate a reserved subscription with its stream, forwarding stream @@ -189,6 +234,7 @@ impl SubscriptionManager { } let active = self.active.clone(); + let request_senders = self.request_senders.clone(); let future: BoxFuture<'static, ()> = Box::pin(async move { let completed = { @@ -239,6 +285,16 @@ impl SubscriptionManager { owned }; + if removed { + let mut senders = request_senders.lock().unwrap(); + let owned = matches!( + senders.get(&request_id), + Some((sender_generation, _, _)) if *sender_generation == generation + ); + if owned { + senders.remove(&request_id); + } + } if completed && removed { transport.send(ProtocolMessage { request_id, @@ -283,6 +339,22 @@ impl SubscriptionManager { } None => {} } + drop(active); + self.request_senders.lock().unwrap().remove(request_id); + } + + /// Deliver one product-to-host value to an active paired subscription. + pub fn handle_request(&self, request_id: &str, receive_id: u8, value: Vec) { + let sender = self + .request_senders + .lock() + .unwrap() + .get(request_id) + .filter(|(_, expected_receive_id, _)| *expected_receive_id == receive_id) + .map(|(_, _, sender)| sender.clone()); + if let Some(sender) = sender { + let _ = sender.unbounded_send(value); + } } /// Cancel and forget every pending or live subscription owned by this @@ -302,6 +374,18 @@ impl SubscriptionManager { for cancel in cancellations { cancel(); } + self.request_senders.lock().unwrap().clear(); + } + + fn remove_request_sender(&self, request_id: &str, generation: u64) { + let mut senders = self.request_senders.lock().unwrap(); + let owned = matches!( + senders.get(request_id), + Some((sender_generation, _, _)) if *sender_generation == generation + ); + if owned { + senders.remove(request_id); + } } } @@ -309,6 +393,7 @@ impl SubscriptionManager { mod tests { use super::*; use futures::stream; + use parity_scale_codec::Encode; use std::sync::atomic::{AtomicUsize, Ordering}; use std::task::{Context, Poll}; @@ -367,6 +452,16 @@ mod tests { )) } + #[test] + fn malformed_paired_request_terminates_only_its_typed_stream() { + let raw: SubscriptionRequestStream = + Box::pin(stream::iter([7_u32.encode(), vec![0xff], 9_u32.encode()])); + let mut typed = subscription_request_stream::(raw); + + assert_eq!(futures::executor::block_on(typed.next()), Some(7)); + assert_eq!(futures::executor::block_on(typed.next()), None); + } + struct PendingDropStream { dropped: Arc, } diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 74ea5998..6e5e46c3 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -24,7 +24,7 @@ use send_wrapper::SendWrapper; use truapi::v01; use truapi_platform::{ ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, - RuntimeConfigValidationError, + ProductExecutionKind, RuntimeConfigValidationError, }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -480,12 +480,22 @@ fn product_context_from_js(value: &JsValue) -> Result { if value.is_null() || value.is_undefined() { return Err(JsValue::from_str("product is required")); } - ProductContext::new(get_required_string_at( - value, - "productId", - "runtimeConfig.productId", - )?) - .map_err(runtime_config_validation_to_js) + let product_id = get_required_string_at(value, "productId", "runtimeConfig.productId")?; + let execution_kind = + match get_optional_string_at(value, "executionKind", "runtimeConfig.executionKind")? + .as_deref() + { + None | Some("App") => ProductExecutionKind::App, + Some("Widget") => ProductExecutionKind::Widget, + Some("Chat") => ProductExecutionKind::Chat, + Some(other) => { + return Err(JsValue::from_str(&format!( + "runtimeConfig.executionKind must be App, Widget, or Chat, got {other:?}" + ))); + } + }; + ProductContext::new_with_execution(product_id, execution_kind) + .map_err(runtime_config_validation_to_js) } fn runtime_config_field_to_js(field: &str) -> &str { diff --git a/rust/crates/truapi/src/api/chat.rs b/rust/crates/truapi/src/api/chat.rs index c9e9e826..e7e2722b 100644 --- a/rust/crates/truapi/src/api/chat.rs +++ b/rust/crates/truapi/src/api/chat.rs @@ -11,6 +11,7 @@ use crate::wire; use crate::{CallContext, CallError, Subscription}; /// Chat room, bot, and message APIs. +#[crate::service(required_execution = Chat)] #[crate::async_trait] pub trait Chat: Send + Sync { /// Create a chat room. @@ -105,31 +106,27 @@ pub trait Chat: Send + Sync { Subscription::empty() } - /// Subscribe to custom message render requests from the host. Each - /// emitted item is a [`CustomRendererNode`](crate::v01::CustomRendererNode) - /// tree describing the rendered UI. + /// Open the paired custom-message renderer streams. /// - /// ```ts - /// import { firstValueFrom, from } from "rxjs"; + /// The product subscribes to the returned stream for render work and + /// publishes replacement trees through `requests`. /// - /// const item = await firstValueFrom( - /// from( - /// truapi.chat.customMessageRenderSubscribe({ - /// request: { - /// messageId: "msg-1", - /// messageType: "custom-render-demo", - /// payload: "0x", - /// }, - /// }), - /// ), - /// ); - /// console.log("render request received:", item); + /// ```ts + /// const renderer = truapi.chat.customMessageRenderSubscribe(); + /// renderer.responses.subscribe({ + /// next(item) { + /// renderer.requests.next({ + /// tag: "Failed", + /// value: { messageId: item.messageId }, + /// }); + /// }, + /// }); /// ``` #[wire(start_id = 52)] async fn custom_message_render_subscribe( &self, _cx: &CallContext, - _request: ProductChatCustomMessageRenderSubscribeRequest, + _requests: Subscription, ) -> Subscription { Subscription::empty() } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a138df03..c560a875 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -47,6 +47,28 @@ pub mod latest { /// Ring VRF proof creation result. pub type HostAccountCreateProofResponse = LatestOf; + /// Chat action delivered from the native host to a product worker. + pub type HostChatActionSubscribeItem = LatestOf; + /// Native chat room creation request. + pub type HostChatCreateRoomRequest = LatestOf; + /// Native chat room creation result. + pub type HostChatCreateRoomResponse = LatestOf; + /// Native chat room creation failure. + pub type HostChatCreateRoomError = LatestOf; + /// Current native room list for a product. + pub type HostChatListSubscribeItem = LatestOf; + /// Native chat message posting request. + pub type HostChatPostMessageRequest = LatestOf; + /// Native chat message posting result. + pub type HostChatPostMessageResponse = LatestOf; + /// Native chat message posting failure. + pub type HostChatPostMessageError = LatestOf; + /// Product-to-host custom renderer update. + pub type ProductChatCustomMessageRenderSubscribeRequest = + LatestOf; + /// Host-to-product custom render work item. + pub type ProductChatCustomMessageRenderSubscribeItem = + LatestOf; /// Contextual alias derivation result. pub type HostAccountGetAliasResponse = LatestOf; @@ -124,7 +146,7 @@ pub mod latest { pub type RemotePermissionResponse = LatestOf; } -pub use truapi_macros::wire; +pub use truapi_macros::{service, wire}; /// Per-message id carried from the transport frame. pub type RequestId = String; diff --git a/rust/crates/truapi/src/v01/chat/custom_renderer.rs b/rust/crates/truapi/src/v01/chat/custom_renderer.rs index 15e3a6f5..ccbfde47 100644 --- a/rust/crates/truapi/src/v01/chat/custom_renderer.rs +++ b/rust/crates/truapi/src/v01/chat/custom_renderer.rs @@ -309,14 +309,30 @@ pub enum CustomRendererNode { TextField(Component), } -/// Subscribe payload identifying the chat message to render. The host responds -/// with a stream of [`CustomRendererNode`] trees describing the rendered UI. +/// Values sent by a product on its custom-message renderer request stream. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ProductChatCustomMessageRenderSubscribeRequest { - /// Message identifier. +pub enum ProductChatCustomMessageRenderSubscribeRequest { + /// Replace the native tree for an active render instance. + Update { + /// Identifier supplied by the host in the corresponding render item. + message_id: String, + /// Complete replacement tree produced by the product renderer. + node: CustomRendererNode, + }, + /// Report that the product cannot render one requested message. + Failed { + /// Identifier supplied by the host in the corresponding render item. + message_id: String, + }, +} + +/// Custom-message render work sent by the host to the product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ProductChatCustomMessageRenderSubscribeItem { + /// Stable identifier used to correlate updates and widget actions. pub message_id: String, - /// Application-defined message type. + /// Product-defined discriminator used to select a renderer. pub message_type: String, - /// Binary payload. + /// Stored product-defined message payload. pub payload: Vec, } diff --git a/rust/crates/truapi/src/versioned/chat.rs b/rust/crates/truapi/src/versioned/chat.rs index 5c821275..bfa2f85e 100644 --- a/rust/crates/truapi/src/versioned/chat.rs +++ b/rust/crates/truapi/src/versioned/chat.rs @@ -15,5 +15,5 @@ truapi_macros::versioned_type! { pub enum HostChatListSubscribeItem { V1 => v01::HostChatListSubscribeItem } pub enum HostChatActionSubscribeItem { V1 => v01::HostChatActionSubscribeItem } pub enum ProductChatCustomMessageRenderSubscribeRequest { V1 => v01::ProductChatCustomMessageRenderSubscribeRequest } - pub enum ProductChatCustomMessageRenderSubscribeItem { V1 => v01::CustomRendererNode } + pub enum ProductChatCustomMessageRenderSubscribeItem { V1 => v01::ProductChatCustomMessageRenderSubscribeItem } } diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 7339965b..3b32414d 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -24,8 +24,10 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -cargo +nightly rustdoc -p truapi -- -Z unstable-options --output-format json -cargo +nightly rustdoc -p truapi-platform -- -Z unstable-options --output-format json +NIGHTLY_TOOLCHAIN="${TRUAPI_NIGHTLY_TOOLCHAIN:-nightly}" + +cargo +"$NIGHTLY_TOOLCHAIN" rustdoc -p truapi -- -Z unstable-options --output-format json +cargo +"$NIGHTLY_TOOLCHAIN" rustdoc -p truapi-platform -- -Z unstable-options --output-format json cargo run -p truapi-codegen -- \ --input target/doc/truapi.json \ --output js/packages/truapi/src/generated \ @@ -39,7 +41,7 @@ cargo run -p truapi-codegen -- \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 -rustfmt +nightly --edition 2024 \ +rustfmt +"$NIGHTLY_TOOLCHAIN" --edition 2024 \ rust/crates/truapi-server/src/generated/dispatcher.rs \ rust/crates/truapi-server/src/generated/wire_table.rs \ rust/crates/truapi-server/src/wasm/generated_bridge.rs diff --git a/scripts/launch-ios-chat-playground.mjs b/scripts/launch-ios-chat-playground.mjs new file mode 100755 index 00000000..c5f8881b --- /dev/null +++ b/scripts/launch-ios-chat-playground.mjs @@ -0,0 +1,417 @@ +#!/usr/bin/env node +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + unlinkSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { dirname, extname, resolve, sep } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const bundle = + process.env.TRUAPI_IOS_E2E_BUNDLE ?? "io.pcf.polkadotapp.develop"; +const app = + process.env.TRUAPI_IOS_E2E_APP ?? + resolve( + repoRoot, + "hosts/ios/build/DerivedData/Build/Products/Debug-iphonesimulator/polkadot-app.app", + ); +const productRoot = resolve( + repoRoot, + process.env.TRUAPI_IOS_E2E_CHAT_PRODUCT_DIR ?? "playground", +); +const productHost = + process.env.TRUAPI_IOS_E2E_CHAT_PRODUCT_HOST ?? "truapi-playground.dot"; +const productName = + process.env.TRUAPI_IOS_E2E_CHAT_PRODUCT_NAME ?? "TrUAPI Playground"; +const roomId = process.env.TRUAPI_IOS_E2E_CHAT_ROOM_ID ?? "truapi-playground"; +const message = process.env.TRUAPI_IOS_E2E_CHAT_MESSAGE ?? "!echo hello"; +const expectedReply = + process.env.TRUAPI_IOS_E2E_CHAT_EXPECTED_REPLY ?? "Echo: hello"; +const expectedStartupMessage = + process.env.TRUAPI_IOS_E2E_CHAT_EXPECTED_STARTUP_MESSAGE ?? + 'Chat API checks passed. Send "!echo " to test actions.'; +const expectCustomRenderer = + process.env.TRUAPI_IOS_E2E_CHAT_EXPECT_CUSTOM_RENDERER !== "0"; +const worker = resolve(productRoot, "out/worker/index.js"); +const productUrl = + process.env.TRUAPI_IOS_E2E_CHAT_PRODUCT_URL ?? "http://127.0.0.1:3100"; +const screenshot = resolve( + repoRoot, + process.env.TRUAPI_IOS_E2E_CHAT_SCREENSHOT ?? + "artifacts/truapi-playground-chat.png", +); + +if (!existsSync(app)) { + throw new Error(`iOS app bundle not found: ${app}`); +} +if (!existsSync(resolve(productRoot, "package.json"))) { + throw new Error(`Chat product source not found: ${productRoot}`); +} + +const linkedTruapiRoot = process.env.TRUAPI_IOS_E2E_CHAT_TRUAPI_DIR; +if (linkedTruapiRoot) { + const truapiRoot = resolve(repoRoot, linkedTruapiRoot); + run("yarn", ["build"], { cwd: truapiRoot }); + run("yarn", ["link"], { cwd: truapiRoot }); + run("yarn", ["link", "@parity/truapi"], { cwd: productRoot }); +} + +if (process.env.TRUAPI_IOS_E2E_SKIP_PRODUCT_BUILD !== "1") { + run("yarn", ["build"], { cwd: productRoot }); +} +if (!existsSync(worker)) { + throw new Error(`Chat product worker not found after build: ${worker}`); +} + +const device = selectSimulator(); +run("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", device.udid], { + stdio: "ignore", +}); +if (device.state !== "Booted") { + run("xcrun", ["simctl", "boot", device.udid]); +} +run("xcrun", ["simctl", "bootstatus", device.udid, "-b"]); +run("xcrun", ["simctl", "install", device.udid, app]); + +const appData = capture("xcrun", [ + "simctl", + "get_app_container", + device.udid, + bundle, + "data", +]).trim(); +const connectionMarkers = [ + resolve(appData, "tmp/truapi-e2e", `connected-app-${productHost}`), + resolve(appData, "tmp/truapi-e2e", `connected-chat-${productHost}`), +]; +const customRendererMarker = resolve( + appData, + "tmp/truapi-e2e/custom-renderer-update", +); +for (const marker of [...connectionMarkers, customRendererMarker]) { + if (existsSync(marker)) { + unlinkSync(marker); + } +} +const workerDestination = resolve( + appData, + "Library/Application Support/Products", + productHost, + "ChatExtension/index.js", +); +mkdirSync(resolve(workerDestination, ".."), { recursive: true }); +cpSync(worker, workerDestination); + +const userDataDatabase = resolve( + appData, + "Library/Application Support/group.pcf.polkadotapp/CoreData/UserDataModel.sqlite", +); +const chatIdentifier = `1:${productHost}:${roomId}`; +const messageWatermark = existsSync(userDataDatabase) + ? latestMessageId(userDataDatabase, chatIdentifier) + : 0; + +const productServer = await startProductServer( + productUrl, + resolve(productRoot, "out"), +); +try { + run( + "xcrun", + ["simctl", "launch", "--terminate-running-process", device.udid, bundle], + { + env: { + ...process.env, + SIMCTL_CHILD_RUST_BACKTRACE: "1", + SIMCTL_CHILD_TRUAPI_IOS_E2E_BROWSE: "1", + SIMCTL_CHILD_TRUAPI_IOS_E2E_PRODUCT_HOST: productHost, + SIMCTL_CHILD_TRUAPI_IOS_E2E_PRODUCT_URL: productUrl, + SIMCTL_CHILD_TRUAPI_IOS_E2E_CHAT_PRODUCT_HOST: productHost, + SIMCTL_CHILD_TRUAPI_IOS_E2E_CHAT_PRODUCT_NAME: productName, + SIMCTL_CHILD_TRUAPI_IOS_E2E_CHAT_ROOM_ID: roomId, + SIMCTL_CHILD_TRUAPI_IOS_E2E_CHAT_MESSAGE: message, + SIMCTL_CHILD_TRUAPI_IOS_E2E_OPEN_CHAT: "1", + SIMCTL_CHILD_TRUAPI_IOS_E2E_RUNTIME_MARKERS: "1", + }, + }, + ); + + await waitForFiles(connectionMarkers, 30_000); + if (expectedStartupMessage) { + await waitForReply( + userDataDatabase, + chatIdentifier, + messageWatermark, + expectedStartupMessage, + ); + } + await waitForReply( + userDataDatabase, + chatIdentifier, + messageWatermark, + expectedReply, + ); + if (expectCustomRenderer) { + await waitForFiles([customRendererMarker], 30_000); + } + await delay(2_000); + mkdirSync(dirname(screenshot), { recursive: true }); + run("xcrun", ["simctl", "io", device.udid, "screenshot", screenshot]); +} finally { + productServer?.close(); +} + +console.log( + JSON.stringify({ + device: device.name, + deviceId: device.udid, + app, + bundle, + productHost, + productName, + roomId, + message, + expectedReply, + expectedStartupMessage, + customRendererVerified: expectCustomRenderer, + productUrl, + connectedExecutions: ["App", "Chat"], + worker, + workerDestination, + screenshot, + verified: true, + }), +); + +async function startProductServer(urlString, root) { + const url = new URL(urlString); + if (!["127.0.0.1", "localhost", "::1", "[::1]"].includes(url.hostname)) { + throw new Error( + `Product URL must be loopback for this E2E test: ${urlString}`, + ); + } + + try { + const response = await fetch(urlString); + if (response.ok && (await response.text()).includes(productName)) { + return null; + } + throw new Error(`${urlString} is serving a different application`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("different application") + ) { + throw error; + } + } + + const server = createServer((request, response) => { + try { + const pathname = decodeURIComponent( + new URL(request.url ?? "/", urlString).pathname, + ); + let file = resolve(root, `.${pathname}`); + if (file !== root && !file.startsWith(`${root}${sep}`)) { + response.writeHead(403).end(); + return; + } + if (statSync(file).isDirectory()) { + file = resolve(file, "index.html"); + } + response.setHeader("Content-Type", contentType(file)); + const content = readFileSync(file); + response.end( + extname(file) === ".html" + ? injectAppConnectionProbe(content.toString("utf8")) + : content, + ); + } catch { + response.writeHead(404).end(); + } + }); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(Number(url.port || 80), url.hostname, resolveListen); + }); + return server; +} + +function injectAppConnectionProbe(html) { + const probe = ``; + return html.replace("", `${probe}`); +} + +function contentType(file) { + switch (extname(file)) { + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + return "application/json"; + case ".png": + return "image/png"; + case ".svg": + return "image/svg+xml"; + default: + return "application/octet-stream"; + } +} + +async function waitForFiles(files, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (files.every(existsSync)) { + return; + } + await delay(250); + } + throw new Error( + `Timed out waiting for execution connections: ${files.join(", ")}`, + ); +} + +function latestMessageId(database, identifier) { + const query = ` + SELECT COALESCE(MAX(message.Z_PK), 0) + FROM ZCDCHATMESSAGE AS message + JOIN ZCDCHAT AS chat ON chat.Z_PK = message.ZCHAT + WHERE chat.ZIDENTIFIER = ${sqlString(identifier)}; + `; + const value = capture("sqlite3", [database, query]).trim(); + return Number.parseInt(value, 10) || 0; +} + +async function waitForReply(database, identifier, afterMessageId, reply) { + const expectedPayload = Buffer.concat([ + Buffer.of(0), + encodeScaleCompact(Buffer.byteLength(reply, "utf8")), + Buffer.from(reply, "utf8"), + ]) + .toString("hex") + .toUpperCase(); + const deadline = Date.now() + 30_000; + + while (Date.now() < deadline) { + if (existsSync(database)) { + const query = ` + SELECT COUNT(*) + FROM ZCDCHATMESSAGE AS message + JOIN ZCDCHAT AS chat ON chat.Z_PK = message.ZCHAT + JOIN ZCDMESSAGECONTENT AS content ON content.Z_PK = message.ZCONTENT + WHERE chat.ZIDENTIFIER = ${sqlString(identifier)} + AND message.Z_PK > ${afterMessageId} + AND hex(content.ZDATA) = ${sqlString(expectedPayload)}; + `; + if (capture("sqlite3", [database, query]).trim() !== "0") { + return; + } + } + await delay(250); + } + + throw new Error( + `Timed out waiting for ${JSON.stringify(reply)} in ${identifier}`, + ); +} + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +function encodeScaleCompact(value) { + if (value < 1 << 6) { + return Buffer.of(value << 2); + } + if (value < 1 << 14) { + const encoded = (value << 2) | 1; + const result = Buffer.allocUnsafe(2); + result.writeUInt16LE(encoded); + return result; + } + if (value < 1 << 30) { + const encoded = value * 4 + 2; + const result = Buffer.allocUnsafe(4); + result.writeUInt32LE(encoded); + return result; + } + throw new Error("Expected reply is too large for this E2E assertion"); +} + +function sqlString(value) { + return `'${value.replaceAll("'", "''")}'`; +} + +function selectSimulator() { + const requested = + process.env.TRUAPI_IOS_E2E_DEVICE ?? process.env.IOS_SIMULATOR_DEVICE; + const simulatorList = JSON.parse( + capture("xcrun", ["simctl", "list", "devices", "available", "-j"]), + ); + const devices = Object.values(simulatorList.devices) + .flat() + .filter( + (candidate) => + candidate.isAvailable && candidate.name.startsWith("iPhone"), + ); + const selected = requested + ? devices.find( + (candidate) => + candidate.udid === requested || candidate.name === requested, + ) + : (devices.find((candidate) => candidate.state === "Booted") ?? devices[0]); + + if (!selected) { + throw new Error( + requested + ? `Requested iPhone simulator is unavailable: ${requested}` + : "No available iPhone simulator found", + ); + } + return selected; +} + +function capture(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${result.status}`, + ); + } + return result.stdout; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${result.status}`, + ); + } +} diff --git a/scripts/launch-ios-playground.mjs b/scripts/launch-ios-playground.mjs new file mode 100644 index 00000000..01ff850d --- /dev/null +++ b/scripts/launch-ios-playground.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const bundle = + process.env.TRUAPI_IOS_E2E_BUNDLE ?? "io.pcf.polkadotapp.develop"; +const app = + process.env.TRUAPI_IOS_E2E_APP ?? + resolve( + repoRoot, + "hosts/ios/build/DerivedData/Build/Products/Debug-iphonesimulator/polkadot-app.app", + ); +const productHost = + process.env.TRUAPI_IOS_E2E_PRODUCT_HOST ?? "truapi-playground.dot"; +const productUrl = + process.env.TRUAPI_IOS_E2E_PRODUCT_URL ?? "http://localhost:3100"; + +if (!existsSync(app)) { + throw new Error(`iOS app bundle not found: ${app}`); +} + +const playgroundProcess = await ensurePlayground(); +const device = selectSimulator(); + +run("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", device.udid], { + stdio: "ignore", +}); +if (device.state !== "Booted") { + run("xcrun", ["simctl", "boot", device.udid]); +} +run("xcrun", ["simctl", "bootstatus", device.udid, "-b"]); +run("xcrun", ["simctl", "install", device.udid, app]); +const signingHostSession = readSigningHostSession(device.udid); +run( + "xcrun", + ["simctl", "launch", "--terminate-running-process", device.udid, bundle], + { + env: { + ...process.env, + SIMCTL_CHILD_RUST_BACKTRACE: "1", + SIMCTL_CHILD_TRUAPI_IOS_E2E_BROWSE: "1", + SIMCTL_CHILD_TRUAPI_IOS_E2E_PRODUCT_HOST: productHost, + SIMCTL_CHILD_TRUAPI_IOS_E2E_PRODUCT_URL: productUrl, + }, + }, +); + +console.log( + JSON.stringify({ + device: device.name, + deviceId: device.udid, + app, + bundle, + productHost, + productUrl, + signingHostUsername: signingHostSession.username, + signingHost: "truapi-host local session", + }), +); + +if (playgroundProcess) { + console.log("The TrUAPI playground is running; press Ctrl-C to stop it."); + await keepPlaygroundAlive(playgroundProcess); +} + +async function ensurePlayground() { + const initialProbe = await probePlayground(); + if (initialProbe === "ready") { + return null; + } + if (initialProbe === "wrong-product") { + throw new Error( + `${productUrl} is serving a different app; stop it or set TRUAPI_IOS_E2E_PRODUCT_URL`, + ); + } + + const url = new URL(productUrl); + if (!isLoopback(url)) { + throw new Error(`TrUAPI playground is not reachable at ${productUrl}`); + } + + const port = url.port || (url.protocol === "https:" ? "443" : "80"); + const child = spawn( + "yarn", + ["dev", "--hostname", "0.0.0.0", "--port", port], + { + cwd: resolve(repoRoot, "playground"), + env: process.env, + stdio: "inherit", + }, + ); + + try { + await waitForPlayground(child); + return child; + } catch (error) { + child.kill("SIGTERM"); + throw error; + } +} + +async function probePlayground() { + try { + const response = await fetch(productUrl); + if (!response.ok) { + return "unreachable"; + } + const body = await response.text(); + return body.includes("TrUAPI Playground") ? "ready" : "wrong-product"; + } catch { + return "unreachable"; + } +} + +async function waitForPlayground(child) { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`TrUAPI playground exited with ${child.exitCode}`); + } + if ((await probePlayground()) === "ready") { + return; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + throw new Error(`Timed out waiting for TrUAPI playground at ${productUrl}`); +} + +function selectSimulator() { + const requested = + process.env.TRUAPI_IOS_E2E_DEVICE ?? process.env.IOS_SIMULATOR_DEVICE; + const simulatorList = JSON.parse( + capture("xcrun", ["simctl", "list", "devices", "available", "-j"]), + ); + const devices = Object.values(simulatorList.devices) + .flat() + .filter((device) => device.isAvailable && device.name.startsWith("iPhone")); + + const selected = requested + ? devices.find( + (device) => device.udid === requested || device.name === requested, + ) + : (devices.find((device) => device.state === "Booted") ?? devices[0]); + + if (!selected) { + throw new Error( + requested + ? `Requested iPhone simulator is unavailable: ${requested}` + : "No available iPhone simulator found", + ); + } + return selected; +} + +function isLoopback(url) { + return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); +} + +function capture(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${result.status}`, + ); + } + return result.stdout; +} + +function captureOptional(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +function readSigningHostSession(deviceId) { + const installedSession = readSessionForBundle(deviceId, bundle); + if (installedSession.username && installedSession.entropyId) { + return installedSession; + } + + const developmentBundle = "io.pcf.polkadotapp.develop"; + if (bundle !== developmentBundle) { + const developmentSession = readSessionForBundle( + deviceId, + developmentBundle, + ); + if (developmentSession.username && developmentSession.entropyId) { + console.log( + `Reusing the registered ${developmentSession.username} simulator session with the TestFlight configuration.`, + ); + return developmentSession; + } + } + + console.warn( + "No registered iOS username was found on this simulator; complete native onboarding or select a simulator with an existing wallet session.", + ); + return { username: null, entropyId: null }; +} + +function readSessionForBundle(deviceId, appBundle) { + const appData = captureOptional("xcrun", [ + "simctl", + "get_app_container", + deviceId, + appBundle, + "data", + ]); + const username = appData + ? readPlistValue( + resolve( + appData, + "Library/Preferences", + `${appBundle}.plist`, + ), + "username", + ) + : undefined; + + const isDevelopment = appBundle.endsWith(".develop"); + const groupId = isDevelopment + ? "group.pcf.polkadotapp.develop" + : "group.pcf.polkadotapp"; + const appGroup = captureOptional("xcrun", [ + "simctl", + "get_app_container", + deviceId, + appBundle, + groupId, + ]); + const entropyId = appGroup + ? readPlistValue( + resolve(appGroup, "Library/Preferences", `${groupId}.plist`), + "io.polkadot.app.entropy.id", + ) + : undefined; + + return { + username: username || null, + entropyId: entropyId || null, + }; +} + +function readPlistValue(plist, key) { + return captureOptional("/usr/libexec/PlistBuddy", [ + "-c", + `Print :${key}`, + plist, + ]); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${result.status}`, + ); + } +} + +async function keepPlaygroundAlive(child) { + await new Promise((resolveWait, reject) => { + const stop = () => child.kill("SIGTERM"); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + child.once("error", reject); + child.once("exit", (code, signal) => { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + if (code === 0 || signal === "SIGTERM") { + resolveWait(); + } else { + reject(new Error(`TrUAPI playground exited with ${code ?? signal}`)); + } + }); + }); +} From 21e143bb68e56715329733d2d9e8e8b51b722846 Mon Sep 17 00:00:00 2001 From: pg Date: Sat, 1 Aug 2026 07:27:17 +0200 Subject: [PATCH 2/5] build: pin the codegen nightly toolchain --- .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/deploy-docs.yml | 4 ++-- .github/workflows/deploy-playground.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- CLAUDE.md | 2 +- Makefile | 5 +++-- docs/local-e2e-testing.md | 10 +++++----- rust/crates/truapi-codegen/README.md | 2 +- rust/crates/truapi-codegen/src/main.rs | 2 +- rust/crates/truapi-codegen/src/rustdoc.rs | 4 ++-- .../truapi-codegen/tests/golden_rust_emit.rs | 12 +++++++----- scripts/codegen.sh | 4 ++-- scripts/snapshot-version.sh | 3 ++- 13 files changed, 38 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d583e5b3..d69d84af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,9 @@ jobs: with: persist-credentials: false - - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-01-10 with: - toolchain: nightly + toolchain: nightly-2026-01-10 components: rustfmt, clippy - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # stable @@ -43,11 +43,11 @@ jobs: - name: cargo check wasm32 run: cargo check --target wasm32-unknown-unknown -p truapi-server - - name: cargo +nightly fmt --check - run: cargo +nightly fmt --check + - name: cargo +nightly-2026-01-10 fmt --check + run: cargo +nightly-2026-01-10 fmt --check - - name: cargo +nightly clippy - run: cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings + - name: cargo +nightly-2026-01-10 clippy + run: cargo +nightly-2026-01-10 clippy --workspace --all-targets --all-features -- -D warnings - name: cargo test run: cargo test --workspace --all-features @@ -77,9 +77,9 @@ jobs: with: toolchain: stable - - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-01-10 with: - toolchain: nightly + toolchain: nightly-2026-01-10 components: rustfmt - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 67fbed3b..95d8f169 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -37,9 +37,9 @@ jobs: - name: Configure Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-01-10 with: - toolchain: nightly + toolchain: nightly-2026-01-10 components: rustfmt - name: Install workspace dependencies diff --git a/.github/workflows/deploy-playground.yml b/.github/workflows/deploy-playground.yml index 12ffb659..e9e87f1f 100644 --- a/.github/workflows/deploy-playground.yml +++ b/.github/workflows/deploy-playground.yml @@ -39,9 +39,9 @@ jobs: with: toolchain: stable - - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-01-10 with: - toolchain: nightly + toolchain: nightly-2026-01-10 components: rustfmt - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9860d429..112c279f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,9 +126,9 @@ jobs: fi - if: steps.version.outputs.proceed == 'true' - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-01-10 with: - toolchain: nightly + toolchain: nightly-2026-01-10 components: rustfmt - if: steps.version.outputs.proceed == 'true' diff --git a/CLAUDE.md b/CLAUDE.md index aef6089f..df3c8e45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ After regenerating, rebuild the client and refresh the playground's link copy: ```bash cargo build --workspace -cargo +nightly fmt --check +cargo +nightly-2026-01-10 fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace ``` diff --git a/Makefile b/Makefile index 1253a02d..9721ec84 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ .PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test uniffi uniffi-kotlin ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local check-android-parity dotli-link dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer CARGO ?= cargo +NIGHTLY_TOOLCHAIN ?= nightly-2026-01-10 TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground JS_PACKAGES := js/packages @@ -64,7 +65,7 @@ install: headless ## Install the truapi-host CLI into Cargo's bin dir; use as `m cargo install --path rust/crates/truapi-host-cli --bin truapi-host --locked --force codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. - ./scripts/codegen.sh + TRUAPI_NIGHTLY_TOOLCHAIN="$(NIGHTLY_TOOLCHAIN)" ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install wasm: ## Rebuild the truapi-server WASM artifacts under js/packages/truapi-host/dist/wasm/. @@ -206,7 +207,7 @@ test: ## Run Rust + TypeScript client tests. check: ## Full verification suite (build, fmt, clippy, test, TS tests, playground build + lint). cargo build --workspace cargo check --target wasm32-unknown-unknown -p truapi-server - cargo +nightly fmt --check + cargo +$(NIGHTLY_TOOLCHAIN) fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features --all-targets cd $(TRUAPI_PKG) && npm run build && npm test diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index 1547c917..2442ef53 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -70,7 +70,7 @@ Failure modes: ```bash cargo build --workspace --all-targets --all-features -cargo +nightly fmt --check +cargo +nightly-2026-01-10 fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features ``` @@ -103,8 +103,8 @@ purely TS-side. ``` Expected: `Generated client at js/packages/truapi/src/generated/`. The -script uses `cargo +nightly rustdoc --output-format json` so a missing -nightly toolchain or broken intra-doc links will fail it. Fix doc links +script uses `cargo +nightly-2026-01-10 rustdoc --output-format json` so a +missing pinned nightly toolchain or broken intra-doc links will fail it. Fix doc links that the rustdoc step warns about — they break codegen and look worse in published docs. @@ -291,7 +291,7 @@ authoritative source is `cargo`, not the editor squiggle. ### Broken intra-doc links break codegen -Symptom: `cargo +nightly rustdoc -p truapi` emits +Symptom: `cargo +nightly-2026-01-10 rustdoc -p truapi` emits `unresolved link to ...` warnings, then `truapi-codegen` produces output but you missed an item in the generated TS. @@ -314,7 +314,7 @@ there is no inner type for codegen to emit. A change is end-to-end-verified locally when all of: - [ ] `cargo build/test/clippy --workspace --all-targets --all-features` clean -- [ ] `cargo +nightly fmt --check` clean +- [ ] `cargo +nightly-2026-01-10 fmt --check` clean - [ ] `./scripts/codegen.sh` clean (only if Rust surface changed) - [ ] `npm run build && npm test` in `js/packages/truapi/` clean - [ ] `yarn build && yarn lint` in `playground/` clean (after a fresh diff --git a/rust/crates/truapi-codegen/README.md b/rust/crates/truapi-codegen/README.md index e924f090..06c770bd 100644 --- a/rust/crates/truapi-codegen/README.md +++ b/rust/crates/truapi-codegen/README.md @@ -67,7 +67,7 @@ cargo run -p truapi-codegen -- \ ## Typical workflow ```bash -cargo +nightly rustdoc -p truapi -- -Z unstable-options --output-format json +cargo +nightly-2026-01-10 rustdoc -p truapi -- -Z unstable-options --output-format json cargo run -p truapi-codegen -- \ --input target/doc/truapi.json \ --output js/packages/truapi/src/generated \ diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 9fd3e0ad..81ec7dbc 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -18,7 +18,7 @@ mod ts; about = "Generate TS client from TrUAPI Rust traits" )] struct Cli { - /// Path to rustdoc JSON file (generated by `cargo +nightly rustdoc -p truapi --output-format json`) + /// Path to rustdoc JSON file (generated by `cargo +nightly-2026-01-10 rustdoc -p truapi --output-format json`) #[arg(short, long)] input: String, diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 527eb9cd..77b8e7a5 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -283,13 +283,13 @@ pub fn parse(json: &str) -> Result { let Some(version) = krate.format_version else { bail!( "rustdoc JSON is missing `format_version`; regenerate it with \ - `cargo +nightly rustdoc --output-format json` (nightly 2026-02-23 or later)" + `cargo +nightly-2026-01-10 rustdoc --output-format json`" ); }; if version < MIN_FORMAT_VERSION { bail!( "rustdoc JSON format_version {version} is older than the tested minimum \ - {MIN_FORMAT_VERSION}; regenerate with nightly 2026-02-23 or later" + {MIN_FORMAT_VERSION}; regenerate with nightly-2026-01-10 or another compatible nightly" ); } Ok(krate) diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index d7bdab0e..3623e860 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -1,17 +1,18 @@ //! Golden snapshot test for the Rust dispatcher emitter. //! -//! Each test runs `cargo +nightly rustdoc -p truapi` into its own +//! Each test runs `cargo +nightly-2026-01-10 rustdoc -p truapi` into its own //! `--target-dir` under a per-test tempdir so concurrent test execution //! cannot race on the shared `target/doc/truapi.json` path. Nightly Rust //! is required; if it is not available the test panics rather than -//! silently passing (set up rustup with `rustup toolchain install nightly`). +//! silently passing (install it with +//! `rustup toolchain install nightly-2026-01-10 --component rustfmt`). use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; fn nightly_toolchain() -> String { - std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| "nightly".to_string()) + std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| "nightly-2026-01-10".to_string()) } fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { @@ -49,7 +50,7 @@ fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { strings } -/// Run `cargo +nightly rustdoc -p truapi --output-format json` into the +/// Run `cargo +nightly-2026-01-10 rustdoc -p truapi --output-format json` into the /// given `target_dir` and return the path to the produced JSON file. /// Panics with a clear message if nightly is unavailable so CI cannot /// pass vacuously. @@ -74,7 +75,8 @@ fn produce_rustdoc_json_for_package( ); assert!( output.status.success(), - "`cargo +nightly rustdoc -p {package}` failed (status {}); nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", + "`cargo +{} rustdoc -p {package}` failed (status {}); the pinned nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", + nightly_toolchain(), output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 3b32414d..1c03ff6c 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -2,7 +2,7 @@ # Regenerate js/packages/truapi/src/generated/* from rust/crates/truapi. # # Pipeline: -# 1. cargo +nightly rustdoc -p truapi --output-format json -> target/doc/truapi.json +# 1. cargo +nightly-2026-01-10 rustdoc -p truapi --output-format json -> target/doc/truapi.json # 2. cargo run -p truapi-codegen -- --input target/doc/truapi.json # --output js/packages/truapi/src/generated # --playground-output js/packages/truapi/src/playground @@ -24,7 +24,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -NIGHTLY_TOOLCHAIN="${TRUAPI_NIGHTLY_TOOLCHAIN:-nightly}" +NIGHTLY_TOOLCHAIN="${TRUAPI_NIGHTLY_TOOLCHAIN:-nightly-2026-01-10}" cargo +"$NIGHTLY_TOOLCHAIN" rustdoc -p truapi -- -Z unstable-options --output-format json cargo +"$NIGHTLY_TOOLCHAIN" rustdoc -p truapi-platform -- -Z unstable-options --output-format json diff --git a/scripts/snapshot-version.sh b/scripts/snapshot-version.sh index 1f79a86d..db9ee573 100755 --- a/scripts/snapshot-version.sh +++ b/scripts/snapshot-version.sh @@ -30,6 +30,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" +NIGHTLY_TOOLCHAIN="${TRUAPI_NIGHTLY_TOOLCHAIN:-nightly-2026-01-10}" FORCE=0 WIRE_VERSION="" while [ "$#" -gt 0 ]; do @@ -69,7 +70,7 @@ fi TMP_DIR="$(mktemp -d -t truapi-snapshot.XXXXXX)" trap 'rm -rf "$TMP_DIR"' EXIT -cargo +nightly rustdoc -p truapi -- -Z unstable-options --output-format json >/dev/null +cargo +"$NIGHTLY_TOOLCHAIN" rustdoc -p truapi -- -Z unstable-options --output-format json >/dev/null codegen_args=( --input target/doc/truapi.json From bc81751e9cfa61094f783546e3cb435405606aa3 Mon Sep 17 00:00:00 2001 From: pg Date: Mon, 3 Aug 2026 07:17:20 +0200 Subject: [PATCH 3/5] feat: finalize shared-core chat modality integration --- .github/workflows/diagnosis-report.yml | 20 +- Makefile | 4 +- README.md | 23 +- ...odality-shared-core-implementation-plan.md | 137 ------ docs/design/chat-modality-shared-core.md | 406 +++++------------- explorer/README.md | 6 +- explorer/diagnosis-reports/chat/ios.md | 11 + .../diagnosis-reports/{ => spa}/android.md | 6 - .../diagnosis-reports/{ => spa}/desktop.md | 6 - explorer/diagnosis-reports/{ => spa}/ios.md | 6 - .../{ => spa}/pairing-host-cli.md | 6 - .../{ => spa}/signing-host-cli.md | 6 - explorer/diagnosis-reports/{ => spa}/web.md | 6 - explorer/package.json | 1 + .../scripts/aggregate-diagnosis-matrix.mjs | 83 +++- .../aggregate-diagnosis-matrix.test.mjs | 104 +++++ explorer/src/pages/CompatibilityPage.tsx | 76 +++- js/packages/truapi/src/client.test.ts | 114 ++++- .../src/playground/services-types.test.ts | 28 ++ .../truapi/src/playground/services-types.ts | 17 + js/packages/truapi/src/transport.ts | 10 +- playground/CLAUDE.md | 10 +- playground/README.md | 41 +- playground/package.json | 1 + playground/shared/diagnosis.ts | 50 +++ playground/src/components/DiagnosisView.tsx | 16 +- playground/src/lib/auto-test.ts | 5 +- playground/src/lib/diagnosis-report.ts | 50 +-- playground/src/lib/services.ts | 10 +- playground/tests/e2e/dotli-diagnosis.ts | 9 +- playground/tests/unit/chat-diagnosis.test.ts | 42 ++ playground/tests/unit/diagnosis.test.ts | 40 ++ playground/worker/diagnosis.ts | 163 +++++++ playground/worker/index.ts | 368 +++++++++++----- rust/crates/truapi-codegen/src/rustdoc.rs | 33 ++ rust/crates/truapi-codegen/src/ts.rs | 87 ++-- .../truapi-codegen/src/ts/playground.rs | 10 +- .../truapi-codegen/tests/golden/dispatcher.rs | 8 +- .../tests/golden/host-callbacks.ts | 3 +- .../truapi-codegen/tests/golden/wire_table.rs | 8 +- rust/crates/truapi-host-cli/README.md | 2 +- rust/crates/truapi-host-cli/SPEC.md | 6 +- .../truapi-host-cli/js/diagnosis.test.ts | 6 +- rust/crates/truapi-host-cli/js/diagnosis.ts | 6 +- .../truapi-host-cli/js/scripts/battery.ts | 2 +- rust/crates/truapi-host-cli/src/main.rs | 34 +- rust/crates/truapi-platform/src/lib.rs | 3 +- .../truapi-server/src/generated/dispatcher.rs | 8 +- .../truapi-server/src/generated/wire_table.rs | 8 +- rust/crates/truapi-server/src/host_core.rs | 5 +- rust/crates/truapi-server/src/native.rs | 100 +++-- rust/crates/truapi-server/src/runtime.rs | 10 +- rust/crates/truapi-server/src/runtime/chat.rs | 46 +- rust/crates/truapi/src/api/chat.rs | 24 +- rust/crates/truapi/src/lib.rs | 8 +- .../truapi/src/v01/chat/custom_renderer.rs | 8 +- rust/crates/truapi/src/versioned/chat.rs | 4 +- scripts/battery.sh | 8 +- scripts/launch-ios-chat-playground.mjs | 137 +++--- scripts/lib/chat-diagnosis-report.mjs | 36 ++ scripts/lib/chat-diagnosis-report.test.mjs | 37 ++ 61 files changed, 1570 insertions(+), 958 deletions(-) delete mode 100644 docs/design/chat-modality-shared-core-implementation-plan.md create mode 100644 explorer/diagnosis-reports/chat/ios.md rename explorer/diagnosis-reports/{ => spa}/android.md (84%) rename explorer/diagnosis-reports/{ => spa}/desktop.md (83%) rename explorer/diagnosis-reports/{ => spa}/ios.md (84%) rename explorer/diagnosis-reports/{ => spa}/pairing-host-cli.md (85%) rename explorer/diagnosis-reports/{ => spa}/signing-host-cli.md (85%) rename explorer/diagnosis-reports/{ => spa}/web.md (84%) create mode 100644 explorer/scripts/aggregate-diagnosis-matrix.test.mjs create mode 100644 js/packages/truapi/src/playground/services-types.test.ts create mode 100644 playground/shared/diagnosis.ts create mode 100644 playground/tests/unit/chat-diagnosis.test.ts create mode 100644 playground/tests/unit/diagnosis.test.ts create mode 100644 playground/worker/diagnosis.ts create mode 100644 scripts/lib/chat-diagnosis-report.mjs create mode 100644 scripts/lib/chat-diagnosis-report.test.mjs diff --git a/.github/workflows/diagnosis-report.yml b/.github/workflows/diagnosis-report.yml index 8618ef5b..eccebc57 100644 --- a/.github/workflows/diagnosis-report.yml +++ b/.github/workflows/diagnosis-report.yml @@ -38,21 +38,27 @@ jobs: run: | set -euo pipefail - # Host mode from the report title (`## Truapi Diagnosis`). + # Host mode and modality from the report title + # (`## Truapi [Chat] Diagnosis`). mode=$(printf '%s' "$BODY" \ - | grep -ioP '##\s+Truapi\s+\K(Web|Desktop|Android|iOS)(?=\s+Diagnosis)' \ + | grep -ioP '##\s+Truapi\s+\K(Web|Desktop|Android|iOS)(?=\s+(?:Chat\s+)?Diagnosis)' \ | head -1 || true) if [ -z "$mode" ]; then gh issue comment "$ISSUE" --body \ - "Could not read a host from this report (expected a \`## Truapi Diagnosis\` heading). Not filing a PR." + "Could not read a host from this report (expected a \`## Truapi [Chat] Diagnosis\` heading). Not filing a PR." exit 1 fi + modality=spa + if printf '%s' "$BODY" | grep -iqP '##\s+Truapi\s+(Web|Desktop|Android|iOS)\s+Chat\s+Diagnosis'; then + modality=chat + fi host=$(printf '%s' "$mode" | tr '[:upper:]' '[:lower:]') - branch="diagnosis-report/$host" - file="explorer/diagnosis-reports/$host.md" + branch="diagnosis-report/$modality-$host" + file="explorer/diagnosis-reports/$modality/$host.md" git fetch origin main git switch -C "$branch" origin/main + mkdir -p "$(dirname "$file")" printf '%s\n' "$BODY" > "$file" git config user.name "github-actions[bot]" @@ -64,7 +70,7 @@ jobs: exit 0 fi git add "$file" - git commit -m "diagnosis: update $host report (from #$ISSUE)" + git commit -m "diagnosis: update $modality/$host report (from #$ISSUE)" git push -f "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$branch" # One open PR per host branch: reuse it while it is open, open a new @@ -73,7 +79,7 @@ jobs: url=$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // empty') if [ -z "$url" ]; then url=$(gh pr create --base main --head "$branch" \ - --title "diagnosis: $mode host report" \ + --title "diagnosis: $mode $modality report" \ --body "Updates \`$file\` from the diagnosis in #$ISSUE. The compatibility matrix is regenerated from the reports at build time.") fi diff --git a/Makefile b/Makefile index 9721ec84..f2f7a3d1 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ ios-run: ios-build ## Build and launch the local TrUAPI playground in an iPhone TRUAPI_IOS_E2E_PRODUCT_URL="$(IOS_PRODUCT_URL)" \ node scripts/launch-ios-playground.mjs -ios-chat-run: ios-build ## Verify concurrent TrUAPI Playground App + Chat executions in an iPhone simulator. +ios-chat-run: ios-build ## Run the TrUAPI Playground Chat diagnosis in an iPhone simulator. TRUAPI_IOS_E2E_APP="$(IOS_APP)" \ TRUAPI_IOS_E2E_BUNDLE="$(IOS_BUNDLE)" \ TRUAPI_IOS_E2E_CHAT_PRODUCT_DIR="$(IOS_CHAT_PRODUCT_DIR)" \ @@ -166,6 +166,8 @@ ios-chat-host-playground-run: ios-build ## Verify Host Playground Chat through t TRUAPI_IOS_E2E_CHAT_PRODUCT_NAME="$(IOS_HOST_PLAYGROUND_NAME)" \ TRUAPI_IOS_E2E_CHAT_PRODUCT_URL="$(IOS_HOST_PLAYGROUND_URL)" \ TRUAPI_IOS_E2E_CHAT_ROOM_ID="host-playground-room" \ + TRUAPI_IOS_E2E_CHAT_MESSAGE="!echo hello" \ + TRUAPI_IOS_E2E_CHAT_DIAGNOSIS="0" \ TRUAPI_IOS_E2E_CHAT_EXPECTED_STARTUP_MESSAGE="" \ TRUAPI_IOS_E2E_CHAT_EXPECT_CUSTOM_RENDERER="0" \ TRUAPI_IOS_E2E_CHAT_SCREENSHOT="artifacts/host-playground-chat.png" \ diff --git a/README.md b/README.md index 00e1dbd6..9f3cb189 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,8 @@ controls, and examples. `scripts/battery.sh` drives that CLI from source over every code-generated example and writes both committed compatibility reports: -`explorer/diagnosis-reports/signing-host-cli.md` from a direct signing-host run, -and `pairing-host-cli.md` from a pairing host that the script pairs with a +`explorer/diagnosis-reports/spa/signing-host-cli.md` from a direct signing-host +run, and `spa/pairing-host-cli.md` from a pairing host that the script pairs with a signing host it starts itself. ```bash @@ -166,23 +166,24 @@ does not provision or pair a signer-bot user. To exercise the shared-core Chat path with the first-party TrUAPI Playground worker, build and serve the local product, install its worker into the -simulator app's product storage, and launch both executables: +simulator app's product storage, and open its native Chat application: ```bash make ios-chat-run ``` -The launcher fails unless both the `App` and `Chat` WebSocket connections open, -the worker completes all six Chat API checks, a typed custom-renderer update -reaches the native widget, and a new SCALE-encoded `Echo: hello` reply reaches -CoreData. The worker receives `!echo hello` through `chat_action_subscribe` and -persists the reply through the Rust `ChatPlatform` adapter while the SPA uses -its separate connection to the same process runtime. Both executables build +The launcher verifies the Chat connection and runs a correlated Chat-only +diagnosis. The worker proves create-room idempotency, observes the new room on +the live list subscription, posts text and custom messages, receives +`!diagnose` through `chat_action_subscribe`, and serves live renderer trees. +The launcher also verifies that a renderer update reaches native code and that +the final Markdown report reaches CoreData. It writes the host-labelled report +to `playground/test-results/ios-chat/diagnosis-report.md`. The product builds against the workspace-linked `@parity/truapi`. Override the product source, -identity, SPA URL, room, input, or expected reply with +identity, SPA URL, room, input, or report path with `IOS_CHAT_PRODUCT_DIR`, `IOS_CHAT_PRODUCT_HOST`, `IOS_CHAT_PRODUCT_URL`, `TRUAPI_IOS_E2E_CHAT_ROOM_ID`, `TRUAPI_IOS_E2E_CHAT_MESSAGE`, or -`TRUAPI_IOS_E2E_CHAT_EXPECTED_REPLY`. +`TRUAPI_IOS_E2E_CHAT_REPORT`. The same harness can run the legacy Product SDK worker from the sibling `host-playground` checkout. This target builds the current TrUAPI client, links diff --git a/docs/design/chat-modality-shared-core-implementation-plan.md b/docs/design/chat-modality-shared-core-implementation-plan.md deleted file mode 100644 index 6f316327..00000000 --- a/docs/design/chat-modality-shared-core-implementation-plan.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: "Chat Modality Shared Core Implementation Plan" -type: design -status: proposed -created: 2026-07-31 ---- - -# Chat Modality Shared Core Implementation Plan - -This plan implements the -[Chat Modality on the Shared Rust Core](./chat-modality-shared-core.md) design. -The design document is the source of truth for protocol, runtime, and native -host behavior. Product-side work is defined separately in the -[Chat Modality Product SDK](./chat-modality-product-sdk.md) design. - -## 1. Contract and generated bindings - -- Keep `custom_message_render_subscribe` product-initiated and change it from a - one-way subscription into paired request and response streams. -- Change its request into `Update` and `Failed` variants. -- Change its item into a render-request struct containing `message_id`, - `message_type`, and `payload`. -- Generate a minimal TypeScript `SubjectLike` for product-to-Rust - values and reuse `ObservableLike` for Rust-to-product values. -- Generalize Rust `Subscription` into a direction-neutral typed stream so it - can represent both halves of the operation. -- Use paired streams only for `custom_message_render_subscribe`; ordinary Chat - subscriptions remain one-way. -- Add service-level `required_execution` metadata and annotate the Chat trait - with `Chat`. -- Generate a service registration that wraps all Chat requests, subscriptions, - and stream pairs in the server's `ExecutionFilter`. -- Generate the TypeScript request/response-stream client and the corresponding - Rust dispatcher implementation. -- Generate native `CustomRendererNode` bindings. -- Add conformance tests for stream opening, values in both directions, - renderer updates, cancellation, disconnect, and recursive renderer nodes. - -The Chat service remains one logical product-to-host contract. Custom rendering -uses paired request and response streams supplied by the Rust Chat service. - -## 2. Shared runtime and execution connections - -- Refactor native construction into one process-owned `TrUAPIHostRuntime`. -- Add `open_product_execution(context)` returning a connection-specific - endpoint and control handle. -- Bind each WebSocket token to one immutable `ProductContext`. -- Add `truapi-server/src/middleware/execution.rs` and construct one - `ExecutionFilter` from the connection context. -- Reuse that filter for generated Chat dispatch and native-to-product - `ProductRuntime` methods. -- Keep one frame dispatcher and live-channel registry per connection while - sharing authentication, chain resources, storage services, and policy. -- Add the Chat service, action publisher, bounded connection-scoped action - buffer, renderer-stream routing, message-id correlation, native update - fan-out, connection-level cancellation, and resource limits. -- Atomically drain buffered actions in FIFO order when - `chat_action_subscribe` opens, then route subsequent actions directly. - -### Manifest example - -The -[Host Playground deployment config](https://github.com/paritytech/host-playground/blob/ab7ddb1476881a1ea3c77a4685f94a5ba60b6c72/bulletin-deploy.config.ts#L20-L42) -declares one `app` executable and one `worker` executable whose entrypoint is -`index.js` and whose `includes` value is `{ chat: true, pocket: false }`: - -```ts -executables: [ - { kind: "app", path: "./out" }, - { - kind: "worker", - path: "./out/worker", - entrypoint: "index.js", - includes: { chat: true, pocket: false }, - }, -]; -``` - -`bulletin-deploy` publishes this configuration as DotNS records. At runtime, -the host resolves those records and derives the immutable context for each -executable. - -## 3. iOS reference integration - -- Make `SPAInteractor` open an `App` execution for `index.html`. -- Make `ProductsScriptExecutor` open a `Chat` execution for `worker/index.js` - when its resolved DotNS metadata declares `includes.chat: true`. -- Implement `ChatPlatform` with the existing - `ChatExtensionDiscoverContext`/CoreData behavior. -- Replace `onUserMessage`, `dispatchEvent`, and `renderMessage` with calls on - the Chat connection's `ProductRuntime` handle. -- Delete `onBotStarted` and every chat-specific `evaluate` call. -- Pass generated `CustomRendererNode` values to native SwiftUI rather than - SCALE hex strings. - -## 4. Other hosts - -- Dotli maps a resolved DotNS worker with `includes.chat: true` to a `Chat` - execution and opens its connection through the WASM host runtime. -- Android implements `ChatPlatform` when native chat storage and UI are - available; until then Chat calls return `Unsupported`. -- The CLI may install an explicit in-memory `ChatPlatform` for end-to-end - tests. Its default runtime has no Chat adapter. - -## 5. Verification - -The integration is complete when the following pass without JavaScript -evaluation or `container.js`: - -- SPA and worker connect concurrently to one host runtime with distinct - contexts. -- An `App` request, subscription start, or renderer-stream open targeting Chat - is denied before its handler or `ChatPlatform` adapter runs. -- Native Chat actions and render requests cannot use an `App` connection. -- Native actions published before `chat_action_subscribe` opens are buffered - and delivered in FIFO order before subsequent live actions. -- Filling the startup action buffer returns `ProductRuntimeError::BufferFull`. -- Closing a Chat execution discards its buffer; a replacement connection does - not inherit those actions. -- Room creation reports `New` and `Exists` correctly. -- User text reaches the worker and the reply is persisted. -- Multiple rooms route replies through the action's room id. -- A stored custom message renders after app restart. -- One renderer stream pair multiplexes independent render instances for multiple - live messages. -- The renderer request stream accepts multiple typed `Update` values for the same - message. -- Native cell and widget cleanup emits no product control message. -- Closing the Chat view closes the associated product connection and renderer - stream. -- One action subscription delivers widget actions with the correct message and - action identifiers. -- An unknown message type sends `Failed` and affects only its render instance. -- Worker reconnect closes the old renderer streams and establishes a clean - replacement pair. -- Two products cannot observe each other's rooms, actions, or renders. -- A host without `ChatPlatform` reports `Unsupported` consistently. diff --git a/docs/design/chat-modality-shared-core.md b/docs/design/chat-modality-shared-core.md index 64710888..db0bb417 100644 --- a/docs/design/chat-modality-shared-core.md +++ b/docs/design/chat-modality-shared-core.md @@ -1,7 +1,7 @@ --- title: "Chat Modality on the Shared Rust Core" type: design -status: proposed +status: accepted created: 2026-07-30 --- @@ -9,89 +9,52 @@ created: 2026-07-30 ## Summary -Chat products use the same TrUAPI execution path as SPA products. +Chat products run on the same TrUAPI execution path as SPA products: the +visible app and a headless Chat worker are separate executions, each with its +own connection into a single host-owned Rust runtime, speaking the same +SCALE protocol and generated client. This replaces the mobile `container.js` +bridges and evaluated JavaScript globals. -The visible app and Chat worker remain separate executions with separate -connections. Both connections terminate in the same process-owned Rust runtime -and use the host's native platform services. +Compared with the current mobile architecture, the shared-core integration +changes three things: -## Current architecture +- Chat access becomes a connection policy. The host assigns an immutable + `ProductExecutionKind`, and an `ExecutionFilter` rejects Chat traffic from + other executions. +- Custom rendering replaces `renderMessage` and `chatRenderWidget` with one + product-initiated bidirectional TrUAPI channel. +- Platform-specific `evaluate` and `callNative` code is replaced by + `ProductRuntimeControl`, `ChatPlatform`, and generated native types. -### Legacy iOS chat communication +Execution kinds beyond Chat are outside this design. -The working chat implementation in `polkadot-app-ios-v2` loads both -`container.js` and `worker/index.js` in a headless webview. The bridge has two -directions: - -- **Product to native:** the worker calls the Triangle Chat API. `container.js` - receives the SCALE request and forwards supported operations through - `callNative(method, params)` to `ContainerBridge`, which invokes the Swift - `ProductsNativeApi` implementation. -- **Native to product:** Swift evaluates globals installed by `container.js`, - such as `dispatchUserMessage(...)` and `dispatchChatAction(...)`. - `container.js` converts those calls into Chat actions delivered to the - worker's Triangle subscription. +## Legacy mobile chat bridges -```text -Native iOS app container.js Product worker -(Chat UI / CoreData) (headless WKWebView) (worker/index.js) - | | | - | user submits "!echo hello" | | - | ProductBot.onTextMessage | | - | ProductsScriptExecutor evaluates | | - | dispatchUserMessage( | | - | roomId, text) | | - |--------------------------------->| | - | | creates MessagePosted: | - | | Text("!echo hello") | - | |--------------------------------->| - | | | subscribeAction receives - | | | the action and builds - | | | "Echo: hello" - | | | - | | chatManager.sendMessage( | - | | roomId, Text("Echo: hello")) | - | |<---------------------------------| - | | callNative( | - | | "chatSendTextMessage", ...) | - |<---------------------------------| | - | | | - | ProductsNativeApi persists | | - | the message in CoreData | | - | Native Chat UI displays | | - | "Echo: hello" | | - | | | -``` - -## Target architecture +`polkadot-app-ios-v2` and `polkadot-app-android-v2` run chat products the +same way: a headless webview loads a shared `container.js` bundle plus the +product's `worker/index.js`. Each platform implements bridge code in both +directions: -### Topology +- **Product to native:** `container.js` receives the worker's SCALE requests + and forwards supported operations through `callNative(method, params)`, + into the Swift `ProductsNativeApi` on iOS and through a synchronous + `@JavascriptInterface` into Kotlin handler groups on Android. +- **Native to product:** both platforms evaluate JavaScript globals installed + by `container.js`, such as `dispatchUserMessage(...)`, + `dispatchChatAction(...)`, and `renderMessage(...)`, with Android building + the snippets by string concatenation. -The target design has four structural rules: +These platform-specific bridges sit outside TrUAPI. -- the host process owns one long-lived Rust host runtime -- each executable gets its own connection and per-connection runtime -- `app/index.html` connects as `App`, while a Chat-enabled - `worker/index.js` connects as `Chat` -- both connections share authentication, storage, chain resources, and the - native Chat adapter +## Architecture -On iOS, the existing `ProductTrUAPIHostRuntime` worker integration becomes the -Chat connection into the process-owned host runtime. It keeps the existing -worker launch path: inject the same WebSocket bootstrap as the SPA, then load -`worker/index.js`. The generated TrUAPI Chat API is available on that -`ProductContext(Chat)` connection. It replaces the legacy JavaScript globals -and `container.js` Chat bridge, which are removed. +The architecture has three structural rules: -Chat is an API-backed modality with host-owned native UI. Its modality -declaration selects the Chat worker executable. The current DotNS -`includes.chat` declaration is the existing representation of that association -and may later map to the general `modalities.chat` manifest shape. - -The host derives each immutable context from resolved DotNS product and -executable records. The -[Host Playground deployment config](https://github.com/paritytech/host-playground/blob/ab7ddb1476881a1ea3c77a4685f94a5ba60b6c72/bulletin-deploy.config.ts#L20-L42) -is a concrete example. +- the host owns one long-lived Rust runtime, shared by all of its product + executions +- each executable has its own connection and per-connection runtime +- `app/index.html` connects as `App`, while `worker/index.js` connects as + `Chat`; both share host services ```text +---------------+---------------+ +---------------+---------------+ @@ -121,32 +84,29 @@ is a concrete example. +---------------------------------------------------------+ ``` -Both executions use exactly the same SCALE protocol and generated client. They -differ only in: - -- which product executable was loaded -- the trusted context attached to their connection -- the long-lived protocol streams opened by the product executable -- whether they have a visible DOM +Chat is an API-backed modality with host-owned native UI. The DotNS +`includes.chat` declaration selects `worker/index.js`. The host loads it through +the existing WebSocket bootstrap and derives its context from the resolved +DotNS records. The +[Host Playground deployment config](https://github.com/paritytech/host-playground/blob/ab7ddb1476881a1ea3c77a4685f94a5ba60b6c72/bulletin-deploy.config.ts#L20-L42) +is a concrete example. -### Trusted execution context +## Trusted execution context -The host creates the product connection with context derived from the resolved -DotNS product and executable metadata described above. The existing -`ProductContext` changes only by adding the trusted execution kind: +The host binds the context before product code runs. The product cannot submit +or override its execution kind. A DotNS worker with `includes.chat: true` maps +to `ProductExecutionKind::Chat`. ```rust - pub struct ProductContext { - pub product_id: String, -+ /// Trusted kind derived from the resolved DotNS executable metadata. -+ pub execution_kind: ProductExecutionKind, - } -``` +pub struct ProductContext { + pub product_id: String, + /// Trusted kind of executable attached to this connection by the host. + pub execution_kind: ProductExecutionKind, +} -```rust -/// Classifies the product executable running on a connection. +/// Trusted kind of product executable attached to a TrUAPI connection. pub enum ProductExecutionKind { - /// Visible application entrypoint, such as `app/index.html`. + /// Visible application entrypoint such as `app/index.html`. App, /// Host-embedded product widget entrypoint. Widget, @@ -155,111 +115,38 @@ pub enum ProductExecutionKind { } ``` -The context is bound to the transport endpoint before product code runs. On -iOS, the localhost WebSocket token resolves to this context when Rust accepts -the connection. The JavaScript product cannot submit or override its execution -kind. - -For this design, a DotNS executable declared as `kind: "worker"` with -`includes.chat: true` maps to `ProductExecutionKind::Chat`. Other worker modes -are outside the current scope and can add execution kinds when needed. - -The requirement is declared once on the API trait: +The required execution kind is declared once on the API trait, and +`truapi-codegen` emits the matching server registration: ```rust #[truapi::service(required_execution = Chat)] pub trait Chat: Send + Sync { - // requests, subscriptions, and paired request/response streams + // requests, subscriptions, and channels } ``` -`truapi-codegen` carries that service metadata into the generated server -registration. The generated registration is equivalent to: - -```rust -dispatcher.register_service( - chat, - ExecutionFilter::require(ProductExecutionKind::Chat), -); -``` - -`ExecutionFilter` lives in `truapi-server/src/middleware/execution.rs`. It is -created from the immutable `ProductContext` when the connection's -`ProductRuntime` is built and runs before any Chat handler or stream is opened. -A mismatch returns `Denied`; accepted calls continue to the Chat service's -authentication and `ChatPlatform` checks. - -The native `ProductRuntime` Chat entrypoints call the same filter before -publishing actions or render requests. Consequently, an `App` connection cannot -be used as the Chat connection, even when both executables belong to the same -product. - -### Execution lifecycle - -The host creates the Chat execution, loads its worker, and accepts the worker's -product-initiated subscriptions: - -```text -Native host Shared Rust HostRuntime Product worker - | | | - | create Chat execution | | - |--------------------------------->| | - | load worker/index.js | | - |-------------------------------------------------------------------->| - | | open TrUAPI connection | - | |<---------------------------------| - | | chat_action_subscribe | - | |<---------------------------------| - | | | -``` - -As a startup edge case, actions published before `chat_action_subscribe` opens -are held in a bounded connection-scoped FIFO and drained in order. Filling it -returns `ProductRuntimeError::BufferFull`; closing the connection discards it -rather than carrying actions into a replacement connection. - -The optional renderer and room-list streams are independent; their setup order -is defined by the Product SDK. There is at most one active Chat execution per -product in a host process. - -Closing or replacing an execution terminates all of its protocol streams. -Reconnection creates a new execution, reloads the worker module, and -establishes fresh streams. - -Durable redelivery, acknowledgement, and bot inbox semantics remain outside -this design. - -## Chat flows - -### Custom rendering +`truapi-server` builds `ExecutionFilter` from the immutable context when it +creates the connection's `ProductRuntime`. The filter runs before Chat +handlers, streams, and native entrypoints, so an `App` connection cannot carry +Chat traffic. -The existing `custom_message_render_subscribe` operation remains initiated by -the product. It now has a product-to-Rust request stream and a Rust-to-product -response stream: +## Custom rendering -| Event | Direction | Value | -| -------------------- | --------------- | -------------------------------------- | -| Open stream | Product -> Rust | call `custom_message_render_subscribe` | -| Ask for native UI | Rust -> product | render item | -| Return or replace UI | Product -> Rust | request `Update` | -| Reject render | Product -> Rust | request `Failed` | - -The product opens the operation by subscribing to its response stream. Opening -it carries no application request value. Other Chat subscriptions remain -ordinary one-way subscriptions. - -The resulting Rust API shape is: +`custom_message_render_channel` is a bidirectional stream initiated by the +product. Native sends render work; the product responds with a complete +`CustomRendererNode` (`Update`) or rejects it (`Failed`). Later `Update`s for +the same `message_id` repaint the widget. ```rust -/// Serves custom-message rendering over paired request and response streams. -async fn custom_message_render_subscribe( +/// Serves custom-message rendering over a product-initiated channel. +async fn custom_message_render_channel( &self, _cx: &CallContext, - requests: Subscription, -) -> Subscription; + requests: Subscription, +) -> Subscription; /// Values sent from the product to Rust on the renderer request stream. -pub enum ProductChatCustomMessageRenderSubscribeRequest { +pub enum ProductChatCustomMessageRenderChannelRequest { /// Replaces the native tree for one active render instance. Update { /// Identifier supplied by the host in the corresponding render item. @@ -276,9 +163,9 @@ pub enum ProductChatCustomMessageRenderSubscribeRequest { }, } -/// Render request sent from Rust to the product on the renderer response stream. -pub struct ProductChatCustomMessageRenderSubscribeItem { - /// Stable identifier used to correlate updates and widget actions. +/// Render item sent from Rust to the product on the renderer response stream. +pub struct ProductChatCustomMessageRenderChannelItem { + /// Stable identifier used to correlate updates and triggered actions. pub message_id: String, /// Product-defined discriminator used to select a renderer. @@ -289,33 +176,23 @@ pub struct ProductChatCustomMessageRenderSubscribeItem { } ``` -In the target API, `Subscription` is a direction-neutral typed stream. The -`requests` parameter is the product-to-Rust stream, while the returned -`Subscription` is the Rust-to-product stream. Both are scoped to the same -connection and operation. - -The generated TypeScript API reuses `ObservableLike` for responses and adds a -minimal `SubjectLike` for requests: +The generated TypeScript API takes the request stream as an argument (any +observable source, such as an RxJS `Subject`) and returns the item stream: ```ts -export interface SubjectLike { - next: (value: T) => void; -} - export class ChatClient { - customMessageRenderSubscribe(): { - requests: SubjectLike; - responses: ObservableLike; - }; + customMessageRenderChannel( + requests: ObservableSource, + ): ObservableLike; } ``` -Usage remains close to an existing TrUAPI subscription: - ```ts -const { requests, responses } = truapi.chat.customMessageRenderSubscribe(); +import { Subject } from "rxjs"; -responses.subscribe({ +const requests = new Subject(); + +truapi.chat.customMessageRenderChannel(requests).subscribe({ next(item) { const node = render(item.messageType, item.payload); @@ -327,20 +204,17 @@ responses.subscribe({ }); ``` -Subscribing to `responses` registers the renderer. A text-only Chat product -does not create these streams. Closing the Chat connection closes both; no -application-level start or stop value is required. - ```text Native platform Shared Rust HostRuntime Product worker (Chat UI) (ProductRuntime) (worker/index.js) | | | - | | open renderer streams | + | | open renderer channel | | |<------------------------------------| - | | renderer streams are active | + | | renderer channel is active | | | | - | display Custom(message_id, | | - | message_type, payload) | | + | native cell encounters stored | | + | Custom(message_id, message_type, | | + | payload) and needs its UI | | | render_custom_message(...) | | |------------------------------------->| | | | render item { message_id, | @@ -360,33 +234,23 @@ Native platform Shared Rust HostRuntime Pro | typed replacement node | | |<-------------------------------------| | | | | - | dismiss Chat UIView | | - | destroy native widget trees | close Chat connection | - |------------------------------------->|------------------------------------>| - | | | discard all renderer state - | | | ``` -The stored `message_id` correlates each render item, all of its `Update` values, -and widget actions. Native owns the lifecycle of cells, observers, and the -latest widget tree. Removing a native observer sends no product control -message. Product-side renderer state is scoped to the Chat connection and is -discarded when it closes. `Failed` ends only the matching native render stream; -it does not close the shared renderer streams. - -`CustomRendererNode` crosses the native boundary as a generated Swift or Kotlin -type. SCALE hex strings and separately maintained native decoders are not part -of the interface. +`message_id` correlates render work, updates, and widget actions. `Failed` +ends only that message's native render stream. Native receives generated +Swift or Kotlin renderer types, not SCALE hex or a separate decoder. ## Native host contract -When the native host opens a Chat worker connection, `truapi-server` returns a -concrete `ProductRuntime` bound to that connection. Swift, Kotlin, or another -host binding retains this handle and uses it to send native events through -Rust to the product worker. It is not part of the product-facing TrUAPI API. +The host uses a per-connection `ProductRuntimeControl` to push native events +to the worker and may implement one runtime-wide `ChatPlatform` for product +calls into native chat storage and UI. + +The native binding retains the control handle for the connection's lifetime; +it is not product-facing TrUAPI. ```rust -impl ProductRuntime { +impl ProductRuntimeControl { fn publish_chat_action( &self, action: HostChatActionSubscribeItem, @@ -397,94 +261,60 @@ impl ProductRuntime { message_id: String, message_type: String, payload: Vec, - ) -> Result, ProductRuntimeError>; + ) -> Result, ProductRuntimeError>; } ``` -`publish_chat_action` covers both `MessagePosted` and `ActionTriggered` by -accepting the existing action enum. For example: - -```text -Native Chat UI ProductRuntime Product worker - | | | - | user types "hello" | | - | publish_chat_action(...) | | - |----------------------------->| | - | | MessagePosted(Text("hello")) | - | |------------------------------>| - | | via chat_action_subscribe | - | | | -``` - -`render_custom_message` is called when a native cell needs to display a stored -custom message. It publishes a render item on the active product-initiated -response stream and returns a native stream fed by matching `Update` values -from the request stream. Native owns that stream's observers and rendered -views; dropping them sends nothing to the product. +`publish_chat_action` carries `MessagePosted` and `ActionTriggered`. +`render_custom_message` sends render work and returns the matching `Update` +stream. Native owns its observers and rendered views. In the opposite direction, product-originated room, message, and room-list -calls flow from Rust through the optional `ChatPlatform` adapter. Hosts install -it only when they provide native chat storage and UI: +calls flow through the optional `ChatPlatform` adapter: ```rust pub trait ChatPlatform: Send + Sync { async fn create_room( &self, product: &ProductContext, - request: CreateRoomRequest, - ) -> Result; + request: HostChatCreateRoomRequest, + ) -> Result; async fn post_message( &self, product: &ProductContext, - request: PostMessageRequest, - ) -> Result; + request: HostChatPostMessageRequest, + ) -> Result; - fn room_list( + fn subscribe_rooms( &self, product: &ProductContext, - ) -> PlatformStream; + ) -> BoxStream<'static, HostChatListSubscribeItem>; } ``` -`ChatPlatform` remains the adapter Rust invokes for product requests that -mutate or observe native Chat storage. - -`ChatPlatform` identifies rooms by the trusted `ProductContext.product_id` -together with the product-local `room_id`. It must never treat a -product-supplied `room_id` as a globally authoritative native database key. -Message lookup and mutation are constrained to the same product namespace. - -Exact UniFFI async-stream mechanics may use a generated callback handle, but -the values crossing the boundary remain generated typed values. - ## Failure behavior -Policy and renderer-stream validation are enforced in Rust: +Rust enforces connection policy and renderer-stream validation. | Condition | Result | | ------------------------------------------------- | ---------------------------------------------------- | | Ordinary Chat call is not from a Chat execution | `CallError::Denied` | | Ordinary Chat call has no `ChatPlatform` adapter | `CallError::Unsupported` | -| Ordinary Chat call is unauthenticated | Existing authentication failure | +| Ordinary Chat call is unauthenticated | Standard authentication failure | | Native action targets a closed execution | `ProductRuntimeError::Closed` | -| Native action fills the startup buffer | `ProductRuntimeError::BufferFull` | | Renderer is requested on a non-Chat connection | `ProductRuntimeError::Denied` | -| Chat execution did not open renderer streams | Rendering reports `ProductRuntimeError::Unsupported` | -| Product sends `Failed` for the message type | Only that native render stream ends | +| Chat execution did not open the renderer channel | Rendering reports `ProductRuntimeError::Unsupported` | +| Product sends `Failed` for a message | Only that message's native render stream ends | | Renderer node is malformed or exceeds host limits | Only that native render stream fails | -| A renderer-stream value cannot be decoded | The renderer streams and render instances fail | -| Product disconnects during rendering | Its renderer streams and native streams terminate | - -Every call is scoped from `ProductContext`; product-supplied ids are -never used to select another product connection. +| A renderer-channel value cannot be decoded | The channel and active render instances fail | +| Product disconnects during rendering | Its channel and native render streams terminate | ## References -- [Implementation plan](./chat-modality-shared-core-implementation-plan.md) -- [Product SDK design](./chat-modality-product-sdk.md) - [TrUAPI Protocol Design](./truapi-protocol.md) - [`Chat` Rust trait](../../rust/crates/truapi/src/api/chat.rs) - [`CustomRendererNode` types](../../rust/crates/truapi/src/v01/chat/custom_renderer.rs) -- [Current iOS worker executor](../../hosts/ios/Packages/Products/Sources/Products/Services/ProductsScriptExecutor.swift) -- [Current iOS TrUAPI bridge](../../hosts/ios/Packages/Products/Sources/Products/Services/ProductTrUAPIHostBridge.swift) +- [iOS worker executor](../../hosts/ios/Packages/Products/Sources/Products/Services/ProductsScriptExecutor.swift) +- [iOS TrUAPI host bridge](../../hosts/ios/Packages/Products/Sources/Products/Services/ProductTrUAPIHostBridge.swift) +- [Android host adapter](../../android/truapi-host) diff --git a/explorer/README.md b/explorer/README.md index 23e8ee4f..51f98b46 100644 --- a/explorer/README.md +++ b/explorer/README.md @@ -4,15 +4,15 @@ Docs-only browser for the TrUAPI service surface. All trait and type data is sou ## Host compatibility matrix -The **Compatibility** page (`/v//compatibility`) renders a host × method matrix aggregated from the playground's per-host Diagnosis reports. The committed per-host reports under [`diagnosis-reports/`](diagnosis-reports/) are the source of truth; [`src/data/compatibility.ts`](src/data/compatibility.ts) is a generated artifact (git-ignored) that [`scripts/aggregate-diagnosis-matrix.mjs`](scripts/aggregate-diagnosis-matrix.mjs) rebuilds from those reports at `dev` / `build` / `lint` time (via the `predev` / `prebuild` / `prelint` scripts). It is the **only** runtime-derived data in the explorer; everything else flows from Rust via codegen. +The **Compatibility** page (`/v//compatibility`) renders separate App and Chat host × method matrices aggregated from the playground's per-host Diagnosis reports. The committed per-host reports under [`diagnosis-reports/`](diagnosis-reports/) are the source of truth; [`src/data/compatibility.ts`](src/data/compatibility.ts) is a generated artifact (git-ignored) that [`scripts/aggregate-diagnosis-matrix.mjs`](scripts/aggregate-diagnosis-matrix.mjs) rebuilds from those reports at `dev` / `build` / `lint` time (via the `predev` / `prebuild` / `prelint` scripts). It is the **only** runtime-derived data in the explorer; everything else flows from Rust via codegen. ### Updating the matrix Because the matrix is regenerated from `diagnosis-reports/` on every `dev` / `build` / `lint`, you only ever commit reports, never `src/data/compatibility.ts`. -**From the playground (recommended).** Open the playground in the host you want to (re)measure, run the Diagnosis, and click **Submit report ↗**. That files a pre-filled `diagnosis-report` issue; the [`diagnosis-report`](../.github/workflows/diagnosis-report.yml) workflow writes the report to `diagnosis-reports/.md` and opens (or updates) that host's PR. +**From the playground (recommended).** Open the playground in the host you want to (re)measure, run the Diagnosis, and click **Submit report ↗**. That files a pre-filled `diagnosis-report` issue; the [`diagnosis-report`](../.github/workflows/diagnosis-report.yml) workflow writes the report to `diagnosis-reports/spa/.md` (or `diagnosis-reports/chat/.md` for Chat reports) and opens (or updates) that host's PR. -**By hand.** Click **Copy report** instead (see [`../playground/README.md#diagnosis`](../playground/README.md#diagnosis)), save the markdown to a host-named file (e.g. `web.md`, `desktop.md`, `android.md`, `ios.md`), drop it into [`diagnosis-reports/`](diagnosis-reports/) overwriting that host's previous report, and commit. Run `npm run generate-matrix` from `explorer/` to preview locally (or just `npm run dev`, which regenerates first). The Compatibility page and each method's Host support row pick up the new data on the next build / Vite HMR. +**By hand.** Click **Copy report** instead (see [`../playground/README.md#diagnosis`](../playground/README.md#diagnosis)), save the markdown to a host-named file under [`diagnosis-reports/spa/`](diagnosis-reports/spa/) (e.g. `spa/web.md`, `spa/desktop.md`, `spa/android.md`, `spa/ios.md`), overwriting that host's previous report, and commit. Chat worker reports live under [`diagnosis-reports/chat/`](diagnosis-reports/chat/), such as `chat/ios.md`, and are rendered in the separate Chat section. Run `npm run generate-matrix` from `explorer/` to preview locally (or just `npm run dev`, which regenerates first). The Compatibility page and each method's Host support row pick up the new data on the next build / Vite HMR. ### Data shape diff --git a/explorer/diagnosis-reports/chat/ios.md b/explorer/diagnosis-reports/chat/ios.md new file mode 100644 index 00000000..43fefe4e --- /dev/null +++ b/explorer/diagnosis-reports/chat/ios.md @@ -0,0 +1,11 @@ +## Truapi iOS Chat Diagnosis + +**5 success · 0 failed** + +| Method | Status | Details | +| --- | --- | --- | +| `Chat/create_room` | ✅ | created once, then returned Exists | +| `Chat/list_subscribe` | ✅ | observed the newly created room | +| `Chat/post_message` | ✅ | posted text and custom messages | +| `Chat/action_subscribe` | ✅ | received MessagePosted with the originating room | +| `Chat/custom_message_render_channel` | ✅ | correlated render work and sent initial and replacement trees | diff --git a/explorer/diagnosis-reports/android.md b/explorer/diagnosis-reports/spa/android.md similarity index 84% rename from explorer/diagnosis-reports/android.md rename to explorer/diagnosis-reports/spa/android.md index 17b7ff45..48b26e13 100644 --- a/explorer/diagnosis-reports/android.md +++ b/explorer/diagnosis-reports/spa/android.md @@ -22,12 +22,6 @@ | `Chain/get_spec_properties` | ✅ | | | `Chain/broadcast_transaction` | ✅ | | | `Chain/stop_transaction` | ✅ | | -| `Chat/create_room` | ❌ | timed out after 10s | -| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/list_subscribe` | ❌ | timed out after 10s | -| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "Unknown", "value": { "reason": "Error: Unknown method: chatSendTextMessage" } } } | -| `Chat/action_subscribe` | ❌ | timed out after 10s | -| `Chat/custom_message_render_subscribe` | ❌ | timed out after 10s | | `Entropy/derive` | ✅ | | | `Local Storage/read` | ✅ | | | `Local Storage/write` | ✅ | | diff --git a/explorer/diagnosis-reports/desktop.md b/explorer/diagnosis-reports/spa/desktop.md similarity index 83% rename from explorer/diagnosis-reports/desktop.md rename to explorer/diagnosis-reports/spa/desktop.md index af6403b4..db6606a8 100644 --- a/explorer/diagnosis-reports/desktop.md +++ b/explorer/diagnosis-reports/spa/desktop.md @@ -22,12 +22,6 @@ | `Chain/get_spec_properties` | ✅ | | | `Chain/broadcast_transaction` | ✅ | | | `Chain/stop_transaction` | ✅ | | -| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/list_subscribe` | ❌ | no elements in sequence | -| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/action_subscribe` | ❌ | no elements in sequence | -| `Chat/custom_message_render_subscribe` | ❌ | timed out after 10s | | `Entropy/derive` | ✅ | | | `Local Storage/read` | ✅ | | | `Local Storage/write` | ✅ | | diff --git a/explorer/diagnosis-reports/ios.md b/explorer/diagnosis-reports/spa/ios.md similarity index 84% rename from explorer/diagnosis-reports/ios.md rename to explorer/diagnosis-reports/spa/ios.md index 7e577842..aa98dc61 100644 --- a/explorer/diagnosis-reports/ios.md +++ b/explorer/diagnosis-reports/spa/ios.md @@ -22,12 +22,6 @@ | `Chain/get_spec_properties` | ✅ | | | `Chain/broadcast_transaction` | ✅ | | | `Chain/stop_transaction` | ✅ | | -| `Chat/create_room` | ❌ | timed out after 10s | -| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/list_subscribe` | ❌ | timed out after 10s | -| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "Unknown", "value": { "reason": "Error: Messages are not supported" } } } | -| `Chat/action_subscribe` | ❌ | timed out after 10s | -| `Chat/custom_message_render_subscribe` | ❌ | timed out after 10s | | `Entropy/derive` | ✅ | | | `Local Storage/read` | ✅ | | | `Local Storage/write` | ✅ | | diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/spa/pairing-host-cli.md similarity index 85% rename from explorer/diagnosis-reports/pairing-host-cli.md rename to explorer/diagnosis-reports/spa/pairing-host-cli.md index c32ca3e5..3196efb5 100644 --- a/explorer/diagnosis-reports/pairing-host-cli.md +++ b/explorer/diagnosis-reports/spa/pairing-host-cli.md @@ -22,12 +22,6 @@ | `Chain/get_spec_properties` | ✅ | | | `Chain/broadcast_transaction` | ✅ | | | `Chain/stop_transaction` | ✅ | | -| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Chat/list_subscribe` | ❌ | no elements in sequence | -| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Chat/action_subscribe` | ❌ | no elements in sequence | -| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | | `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | | `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | | `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/spa/signing-host-cli.md similarity index 85% rename from explorer/diagnosis-reports/signing-host-cli.md rename to explorer/diagnosis-reports/spa/signing-host-cli.md index bf343c44..23c887a0 100644 --- a/explorer/diagnosis-reports/signing-host-cli.md +++ b/explorer/diagnosis-reports/spa/signing-host-cli.md @@ -22,12 +22,6 @@ | `Chain/get_spec_properties` | ✅ | | | `Chain/broadcast_transaction` | ✅ | | | `Chain/stop_transaction` | ✅ | | -| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Chat/list_subscribe` | ❌ | no elements in sequence | -| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Chat/action_subscribe` | ❌ | no elements in sequence | -| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | | `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | | `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | | `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | diff --git a/explorer/diagnosis-reports/web.md b/explorer/diagnosis-reports/spa/web.md similarity index 84% rename from explorer/diagnosis-reports/web.md rename to explorer/diagnosis-reports/spa/web.md index ee13f0e1..23080883 100644 --- a/explorer/diagnosis-reports/web.md +++ b/explorer/diagnosis-reports/spa/web.md @@ -22,12 +22,6 @@ | `Chain/get_spec_properties` | ✅ | | | `Chain/broadcast_transaction` | ✅ | | | `Chain/stop_transaction` | ✅ | | -| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/list_subscribe` | ❌ | no elements in sequence | -| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | -| `Chat/action_subscribe` | ❌ | no elements in sequence | -| `Chat/custom_message_render_subscribe` | ❌ | timed out after 10s | | `Entropy/derive` | ✅ | | | `Local Storage/read` | ✅ | | | `Local Storage/write` | ✅ | | diff --git a/explorer/package.json b/explorer/package.json index 31155f3b..c1baee6a 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -10,6 +10,7 @@ "build": "tsc -b && vite build", "prelint": "npm run generate-matrix", "lint": "tsc -b --noEmit", + "test": "node --test scripts/*.test.mjs", "preview": "vite preview", "generate-matrix": "node scripts/aggregate-diagnosis-matrix.mjs --explorer-out src/data/compatibility.ts diagnosis-reports" }, diff --git a/explorer/scripts/aggregate-diagnosis-matrix.mjs b/explorer/scripts/aggregate-diagnosis-matrix.mjs index d78abca7..a8b3690e 100644 --- a/explorer/scripts/aggregate-diagnosis-matrix.mjs +++ b/explorer/scripts/aggregate-diagnosis-matrix.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Aggregate per-host TrUAPI diagnosis reports into the explorer's committed -// host × method compatibility matrix (columns = hosts, rows = methods), -// MDN browser-compat style. +// App and Chat host × method compatibility matrices (columns = hosts, rows = +// methods), MDN browser-compat style. // // Each input is a diagnosis report as produced by the playground's "Copy // report" button: @@ -11,12 +11,11 @@ // | Method | Status | Details | // | --- | --- | --- | // | `Account/get_account` | ✅ | | -// | `Chat/post_message` | ❌ | host error: not implemented | // ... // -// Keep one report per host you want to (re)measure (a host-named `*.md` file, -// e.g. `web.md`) in the explorer's `diagnosis-reports/` directory and run from -// `explorer/`: +// Reports live in the explorer's `diagnosis-reports/` directory, organized by +// modality: `spa/.md` for App executions and `chat/.md` for Chat +// workers. Run from `explorer/`: // // npm run generate-matrix // @@ -30,12 +29,13 @@ // node scripts/aggregate-diagnosis-matrix.mjs --explorer-out src/data/compatibility.ts diagnosis-reports // // Flags: -// --explorer-out write a TypeScript module exporting `compatibility` +// --explorer-out write `compatibility` and `chatCompatibility` // -// The host column label is the mode from each report's title (Web / Desktop / -// Android / iOS / Unknown). Reports that share a mode are disambiguated with -// their filename. A method missing from a report renders as "—" in the markdown -// view and `null` in the TypeScript module. +// The parent directory selects the matrix: `spa/` reports feed the App matrix +// and `chat/` reports the Chat matrix. For ad-hoc files outside those +// directories, a `... Chat Diagnosis` title selects the Chat matrix and a +// host-agnostic `Truapi Chat Diagnosis` title derives the host from the +// filename. Reports sharing a mode are disambiguated by filename. import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { basename, extname, join } from "node:path"; @@ -50,14 +50,14 @@ const TITLE_RE = /^##\s+Truapi\s+(.+?)\s+Diagnosis\s*$/im; // cell has no `/`. const ROW_RE = /^\|\s*`?([^|`]+?)`?\s*\|\s*([^|]*?)\s*\|\s*(?:(.*?)\s*\|\s*)?$/; -function collectFiles(args) { +function collectFiles(args, fromDirectory = false) { const files = []; for (const arg of args) { if (statSync(arg).isDirectory()) { for (const name of readdirSync(arg).sort()) { - if (extname(name) === ".md") files.push(join(arg, name)); + files.push(...collectFiles([join(arg, name)], true)); } - } else { + } else if (!fromDirectory || extname(arg) === ".md") { files.push(arg); } } @@ -67,7 +67,9 @@ function collectFiles(args) { function parseReport(file) { const text = readFileSync(file, "utf8"); const titleMatch = text.match(TITLE_RE); - const mode = titleMatch ? titleMatch[1].trim() : "Unknown"; + const identity = reportIdentity(file, titleMatch?.[1]); + const directoryModality = modalityFromPath(file); + if (directoryModality) identity.modality = directoryModality; const statuses = new Map(); const details = new Map(); const order = []; @@ -81,7 +83,35 @@ function parseReport(file) { const detail = (m[3] ?? "").trim(); if (detail) details.set(method, detail.replace(/\\\|/g, "|")); } - return { file, mode, statuses, details, order }; + return { file, ...identity, statuses, details, order }; +} + +function reportIdentity(file, title) { + const value = title?.trim() ?? "Unknown"; + if (value === "Chat") { + return { mode: modeFromFilename(file), modality: "Chat" }; + } + if (value.endsWith(" Chat")) { + return { mode: value.slice(0, -" Chat".length), modality: "Chat" }; + } + return { mode: value, modality: "App" }; +} + +// Modality from the report's parent directories: `chat/` -> Chat, `spa/` -> +// App, anything else -> null (fall back to the title heuristics above). +function modalityFromPath(file) { + const directories = file.split(/[\\/]/).slice(0, -1); + if (directories.includes("chat")) return "Chat"; + if (directories.includes("spa")) return "App"; + return null; +} + +function modeFromFilename(file) { + const stem = basename(file, extname(file)); + const match = stem.match(/(?:^|[-_])(web|desktop|android|ios)(?:$|[-_])/i); + if (!match) return "Unknown"; + const mode = match[1].toLowerCase(); + return mode === "ios" ? "iOS" : `${mode[0].toUpperCase()}${mode.slice(1)}`; } function columnLabels(reports) { @@ -193,7 +223,7 @@ function renderMarkdown(reports, labels, methods) { return lines.join("\n") + "\n"; } -function renderTypeScript(matrix) { +function renderTypeScript(matrix, chatMatrix) { return [ "// AUTO-GENERATED by explorer/scripts/aggregate-diagnosis-matrix.mjs.", "// Source: per-host diagnosis reports run from the playground's Diagnosis", @@ -203,6 +233,8 @@ function renderTypeScript(matrix) { "", `export const compatibility: CompatibilityMatrix = ${JSON.stringify(matrix, null, 2)};`, "", + `export const chatCompatibility: CompatibilityMatrix = ${JSON.stringify(chatMatrix, null, 2)};`, + "", ].join("\n"); } @@ -236,17 +268,26 @@ function main() { } const reports = files.map(parseReport); - const labels = columnLabels(reports); - const methods = unionMethodOrder(reports); const generatedAt = new Date().toISOString(); if (explorerOut) { - const matrix = buildMatrix(reports, labels, methods, generatedAt); - writeFileSync(explorerOut, renderTypeScript(matrix)); + const appReports = reports.filter(({ modality }) => modality === "App"); + const chatReports = reports.filter(({ modality }) => modality === "Chat"); + const matrix = matrixForReports(appReports, generatedAt); + const chatMatrix = matrixForReports(chatReports, generatedAt); + writeFileSync(explorerOut, renderTypeScript(matrix, chatMatrix)); console.error(`Wrote ${explorerOut} from ${reports.length} report(s).`); } else { + const labels = columnLabels(reports); + const methods = unionMethodOrder(reports); process.stdout.write(renderMarkdown(reports, labels, methods)); } } +function matrixForReports(reports, generatedAt) { + const labels = columnLabels(reports); + const methods = unionMethodOrder(reports); + return buildMatrix(reports, labels, methods, generatedAt); +} + main(); diff --git a/explorer/scripts/aggregate-diagnosis-matrix.test.mjs b/explorer/scripts/aggregate-diagnosis-matrix.test.mjs new file mode 100644 index 00000000..f8cc70a1 --- /dev/null +++ b/explorer/scripts/aggregate-diagnosis-matrix.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const script = new URL("./aggregate-diagnosis-matrix.mjs", import.meta.url); + +test("keeps App and Chat diagnosis reports in separate matrices", () => { + const directory = mkdtempSync(join(tmpdir(), "truapi-compat-")); + const output = join(directory, "compatibility.ts"); + + writeFileSync( + join(directory, "ios.md"), + report("Truapi iOS Diagnosis", "Account/get_account"), + ); + writeFileSync( + join(directory, "chat-ios.md"), + report("Truapi Chat Diagnosis", "Chat/post_message"), + ); + + execFileSync(process.execPath, [ + script.pathname, + "--explorer-out", + output, + directory, + ]); + + const generated = readFileSync(output, "utf8"); + const app = generated.match( + /export const compatibility: CompatibilityMatrix = ([\s\S]*?);\n\nexport const chatCompatibility/, + )?.[1]; + const chat = generated.match( + /export const chatCompatibility: CompatibilityMatrix = ([\s\S]*?);\n/, + )?.[1]; + + assert.ok(app); + assert.ok(chat); + assert.deepEqual(JSON.parse(app).hosts, [{ label: "iOS", mode: "iOS" }]); + assert.deepEqual(JSON.parse(app).methods.map(({ id }) => id), [ + "Account/get_account", + ]); + assert.deepEqual(JSON.parse(chat).hosts, [{ label: "iOS", mode: "iOS" }]); + assert.deepEqual(JSON.parse(chat).methods.map(({ id }) => id), [ + "Chat/post_message", + ]); +}); + +test("parent directory selects the matrix regardless of title", () => { + const directory = mkdtempSync(join(tmpdir(), "truapi-compat-")); + const output = join(directory, "compatibility.ts"); + + mkdirSync(join(directory, "spa")); + mkdirSync(join(directory, "chat")); + writeFileSync( + join(directory, "spa", "desktop.md"), + report("Truapi Desktop Diagnosis", "Account/get_account"), + ); + writeFileSync( + join(directory, "chat", "desktop.md"), + report("Truapi Desktop Diagnosis", "Chat/post_message"), + ); + + execFileSync(process.execPath, [ + script.pathname, + "--explorer-out", + output, + directory, + ]); + + const generated = readFileSync(output, "utf8"); + const app = generated.match( + /export const compatibility: CompatibilityMatrix = ([\s\S]*?);\n\nexport const chatCompatibility/, + )?.[1]; + const chat = generated.match( + /export const chatCompatibility: CompatibilityMatrix = ([\s\S]*?);\n/, + )?.[1]; + + assert.ok(app); + assert.ok(chat); + assert.deepEqual(JSON.parse(app).hosts, [ + { label: "Desktop", mode: "Desktop" }, + ]); + assert.deepEqual(JSON.parse(app).methods.map(({ id }) => id), [ + "Account/get_account", + ]); + assert.deepEqual(JSON.parse(chat).hosts, [ + { label: "Desktop", mode: "Desktop" }, + ]); + assert.deepEqual(JSON.parse(chat).methods.map(({ id }) => id), [ + "Chat/post_message", + ]); +}); + +function report(title, method) { + return [ + `## ${title}`, + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + `| \`${method}\` | ✅ | worked |`, + ].join("\n"); +} diff --git a/explorer/src/pages/CompatibilityPage.tsx b/explorer/src/pages/CompatibilityPage.tsx index c0da5869..dfb1ea5e 100644 --- a/explorer/src/pages/CompatibilityPage.tsx +++ b/explorer/src/pages/CompatibilityPage.tsx @@ -3,17 +3,20 @@ import { Link, useOutletContext } from "react-router-dom"; import { Check, ChevronDown, Minus, X } from "lucide-react"; import type { VersionEntry } from "../data/types"; import { methodPath } from "../data/registry"; -import { compatibility } from "../data/compatibility"; -import type { CompatStatus } from "../data/compatibility-types"; +import { chatCompatibility, compatibility } from "../data/compatibility"; +import type { + CompatibilityMatrix, + CompatStatus, +} from "../data/compatibility-types"; import { playgroundDiagnosisUrl } from "../data/playground"; /** Per-method host compatibility, aggregated from per-host diagnosis reports. */ export default function CompatibilityPage() { const { version } = useOutletContext<{ version: VersionEntry }>(); - const { generatedAt, hosts, methods } = compatibility; const [expandedId, setExpandedId] = useState(null); + const hostCount = compatibility.hosts.length + chatCompatibility.hosts.length; - if (hosts.length === 0) { + if (hostCount === 0) { return (

@@ -41,8 +44,6 @@ export default function CompatibilityPage() { ); } - const byId = new Map(methods.map((m) => [m.id, m])); - return (
@@ -60,9 +61,12 @@ export default function CompatibilityPage() {

- Aggregated from {hosts.length} host{hosts.length === 1 ? "" : "s"} — - generated{" "} - {generatedAt}. + Aggregated from {hostCount} execution report + {hostCount === 1 ? "" : "s"} — generated{" "} + + {compatibility.generatedAt} + + .

@@ -80,6 +84,52 @@ export default function CompatibilityPage() {
+
+ + +
+

+ ); +} + +function CompatibilitySection({ + title, + description, + matrix, + version, + expandedId, + onToggle, +}: { + title: string; + description: string; + matrix: CompatibilityMatrix; + version: VersionEntry; + expandedId: string | null; + onToggle: (id: string | null) => void; +}) { + if (matrix.hosts.length === 0) return null; + const byId = new Map(matrix.methods.map((method) => [method.id, method])); + + return ( +
+
+

{title}

+

{description}

+
@@ -87,7 +137,7 @@ export default function CompatibilityPage() { - {hosts.map((h) => ( + {matrix.hosts.map((h) => (
Method h.label)} + hosts={matrix.hosts.map((h) => h.label)} versionId={version.id} expandedId={expandedId} - onToggle={setExpandedId} + onToggle={onToggle} /> ))}
- +
); } diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 95063470..3e3f3190 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -28,6 +28,27 @@ function unwrap(result: Result, message: string): T { ); } +/** Minimal replaying request source standing in for an RxJS subject. */ +function requestSource() { + const buffered: Item[] = []; + let target: { next?: (value: Item) => void } | undefined; + return { + next(value: Item): void { + if (target) target.next?.(value); + else buffered.push(value); + }, + subscribe(observer: { next?: (value: Item) => void }): { unsubscribe: () => void } { + target = observer; + for (const value of buffered.splice(0)) observer.next?.(value); + return { + unsubscribe: () => { + target = undefined; + }, + }; + }, + }; +} + /** Create an in-memory provider plus helpers for injecting frames and closes. */ function providerFixture() { const sent: Uint8Array[] = []; @@ -243,23 +264,71 @@ describe("generated client transport", () => { expect(events).toEqual(["Connected"]); }); + it("forwards requests replayed by the source when the channel opens", () => { + const fixture = providerFixture(); + const requests = requestSource(); + const responses = createClient(createTransport(fixture.provider)).chat.customMessageRenderChannel(requests); + const first: T.ProductChatCustomMessageRenderChannelRequest = { + tag: "Failed", + value: { messageId: "message-1" }, + }; + const second: T.ProductChatCustomMessageRenderChannelRequest = { + tag: "Failed", + value: { messageId: "message-2" }, + }; + + requests.next(first); + requests.next(second); + expect(fixture.sent).toHaveLength(0); + + const subscription = responses.subscribe(); + const start = unwrap( + encodeWireMessage({ + requestId: subscription.subscriptionId, + payload: { + id: W.CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL.start, + value: indexedTaggedUnion({ V1: [0, _void] }).enc({ + tag: "V1", + value: undefined, + }), + }, + }), + "encode renderer start", + ); + const requestFrame = (request: T.ProductChatCustomMessageRenderChannelRequest) => + unwrap( + encodeWireMessage({ + requestId: subscription.subscriptionId, + payload: { + id: W.CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL.receive, + value: T.VersionedProductChatCustomMessageRenderChannelRequest.enc({ + tag: "V1", + value: request, + }), + }, + }), + "encode buffered renderer request", + ); + + expect(fixture.sent.map(toHex)).toEqual( + [start, requestFrame(first), requestFrame(second)].map(toHex), + ); + }); + it("uses one subscription id for both renderer stream directions", () => { const fixture = providerFixture(); const transport = createTransport(fixture.provider); const client = createClient(transport); - const renders: T.ProductChatCustomMessageRenderSubscribeItem[] = []; - const renderer = client.chat.customMessageRenderSubscribe(); - - expect(() => - renderer.requests.next({ tag: "Failed", value: { messageId: "too-early" } }), - ).toThrow("subscribe to responses before sending paired-stream requests"); + const renders: T.ProductChatCustomMessageRenderChannelItem[] = []; + const requests = requestSource(); + const responses = client.chat.customMessageRenderChannel(requests); - const subscription = renderer.responses.subscribe({ next: (item) => renders.push(item) }); + const subscription = responses.subscribe({ next: (item) => renders.push(item) }); const start = unwrap( encodeWireMessage({ requestId: subscription.subscriptionId, payload: { - id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.start, + id: W.CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL.start, value: indexedTaggedUnion({ V1: [0, _void] }).enc({ tag: "V1", value: undefined, @@ -280,8 +349,8 @@ describe("generated client transport", () => { encodeWireMessage({ requestId: subscription.subscriptionId, payload: { - id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.receive, - value: T.VersionedProductChatCustomMessageRenderSubscribeItem.enc({ + id: W.CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL.receive, + value: T.VersionedProductChatCustomMessageRenderChannelItem.enc({ tag: "V1", value: item, }), @@ -299,13 +368,13 @@ describe("generated client transport", () => { node: { tag: "String", value: { text: "Votes: 1" } }, }, } as const; - renderer.requests.next(update); + requests.next(update); const request = unwrap( encodeWireMessage({ requestId: subscription.subscriptionId, payload: { - id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.receive, - value: T.VersionedProductChatCustomMessageRenderSubscribeRequest.enc({ + id: W.CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL.receive, + value: T.VersionedProductChatCustomMessageRenderChannelRequest.enc({ tag: "V1", value: update, }), @@ -316,18 +385,19 @@ describe("generated client transport", () => { expect(toHex(fixture.sent[1])).toBe(toHex(request)); }); - it("allows a renderer stream to reopen after the host completes it", () => { + it("stops forwarding after close and refuses a second subscription", () => { const fixture = providerFixture(); const transport = createTransport(fixture.provider); - const renderer = createClient(transport).chat.customMessageRenderSubscribe(); - const first = renderer.responses.subscribe(); + const requests = requestSource(); + const responses = createClient(transport).chat.customMessageRenderChannel(requests); + const first = responses.subscribe(); fixture.receive( unwrap( encodeWireMessage({ requestId: first.subscriptionId, payload: { - id: W.CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE.interrupt, + id: W.CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL.interrupt, value: _void.enc(undefined), }, }), @@ -335,10 +405,12 @@ describe("generated client transport", () => { ), ); - expect(() => - renderer.requests.next({ tag: "Failed", value: { messageId: "closed" } }), - ).toThrow("subscribe to responses before sending paired-stream requests"); - expect(() => renderer.responses.subscribe()).not.toThrow(); + requests.next({ tag: "Failed", value: { messageId: "closed" } }); + expect(fixture.sent).toHaveLength(1); + + expect(() => responses.subscribe()).toThrow( + "channel is single-use: its one subscription is the operation", + ); }); it("completes the observable on a payloadless interrupt terminator", () => { diff --git a/js/packages/truapi/src/playground/services-types.test.ts b/js/packages/truapi/src/playground/services-types.test.ts new file mode 100644 index 00000000..50d13b0b --- /dev/null +++ b/js/packages/truapi/src/playground/services-types.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { servicesForExecution, type ServiceInfo } from "./services-types.js"; +import { services as generatedServices } from "./codegen/services.js"; + +const services: ServiceInfo[] = [ + { name: "Storage", methods: [] }, + { name: "Chat", requiredExecution: "Chat", methods: [] }, + { name: "Widget", requiredExecution: "Widget", methods: [] }, +]; + +describe("servicesForExecution", () => { + test("keeps shared services and services for the selected execution", () => { + expect(servicesForExecution(services, "App").map(({ name }) => name)).toEqual(["Storage"]); + expect(servicesForExecution(services, "Chat").map(({ name }) => name)).toEqual([ + "Storage", + "Chat", + ]); + }); + + test("generated Chat metadata carries its trusted execution requirement", () => { + expect(generatedServices.find(({ name }) => name === "Chat")?.requiredExecution).toBe( + "Chat", + ); + expect( + generatedServices.find(({ name }) => name === "Storage")?.requiredExecution, + ).toBeUndefined(); + }); +}); diff --git a/js/packages/truapi/src/playground/services-types.ts b/js/packages/truapi/src/playground/services-types.ts index cf62fce6..3a38f1aa 100644 --- a/js/packages/truapi/src/playground/services-types.ts +++ b/js/packages/truapi/src/playground/services-types.ts @@ -16,7 +16,24 @@ export interface MethodInfo { errorType?: string; } +/** Trusted executable kind required to access a generated service. */ +export type ProductExecutionKind = "App" | "Widget" | "Chat"; + export interface ServiceInfo { name: string; + /** Executable kind required by the host, or unrestricted when absent. */ + requiredExecution?: ProductExecutionKind; methods: MethodInfo[]; } + +/** Services visible to one trusted executable kind. */ +export function servicesForExecution( + services: ServiceInfo[], + execution: ProductExecutionKind, +): ServiceInfo[] { + return services.filter( + (service) => + service.requiredExecution === undefined || + service.requiredExecution === execution, + ); +} diff --git a/js/packages/truapi/src/transport.ts b/js/packages/truapi/src/transport.ts index 2e61744c..873f8281 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -106,13 +106,15 @@ export interface ObservableLike { } /** - * Minimal write-only stream used by generated paired-stream APIs. + * Observable source accepted by generated channel methods as the + * product-to-host request stream. Structurally satisfied by RxJS subjects and + * observables as well as generated `ObservableLike` values. **/ -export interface SubjectLike { +export interface ObservableSource { /** - * Send one item on the active paired stream. + * Start consuming the source until the returned handle unsubscribes. **/ - next(value: Item): void; + subscribe(observer: Partial>): { unsubscribe(): void }; } /** diff --git a/playground/CLAUDE.md b/playground/CLAUDE.md index 82d68b6e..fe90f076 100644 --- a/playground/CLAUDE.md +++ b/playground/CLAUDE.md @@ -23,6 +23,7 @@ yarn lint # ESLint + tsc --noEmit + tsc -p tsconfig.examples.json yarn lint:fix # Auto-fix ESLint issues yarn typecheck # tsc --noEmit on playground sources yarn typecheck:examples # Typecheck generated client examples +yarn test:unit # Chat diagnosis unit tests yarn e2e # Playwright e2e suite ``` @@ -39,12 +40,15 @@ The Diagnosis screen emits a per-host markdown report via "Copy report". Aggrega | File | Role | | --- | --- | -| `src/lib/services.ts` | Re-exports `services` from `@parity/truapi/playground/services`, which the Rust codegen produces from rustdoc `ts` examples. Read-only. | +| `src/lib/services.ts` | Filters the generated service metadata to APIs available to an `App` execution. Services requiring `Chat` are owned by the worker diagnosis instead. | | `src/lib/transport.ts` | Singleton `Provider`/`Transport`/`TrUApiClient` over iframe postMessage or webview MessagePort. Owns the handshake and connection status. | | `src/lib/example-runner.ts` | Transpiles each rustdoc `ts` example via sucrase, runs it inside an `AsyncFunction` with `truapi`, `console`, rxjs, and an ambient `assert` as bindings. Failure is explicit: an example fails iff it throws (via `assert(...)`, a timeout, or any uncaught error); `console.*` is pure output. A tracking Proxy auto-unsubscribes inner `.subscribe(...)` calls so subscriptions clean up when the run ends or the user navigates away. | | `src/lib/monaco-setup.ts` | Configures Monaco's TS worker: registers the bundled `@parity/truapi` types (`truapi-dts`), every rxjs `.d.ts`, and an ambient block (`declare const truapi: Client`, `assert`, `crypto`, `Uint8Array` hex helpers) so examples typecheck without manual imports. Defines the light/dark themes that match the design tokens. | | `src/lib/auto-test.ts` | Runs each method's example and reports pass / fail. A method passes when its example resolves within the timeout and fails when it throws (the thrown/`assert` message plus any logs become the failure output); unary and subscription examples are awaited identically. `runDiagnosis` runs every method one at a time, in service order; methods that prompt the user (signing, permission/resource requests) block on their host dialog before the run continues. `runSingleTest` replays one method (used by the Diagnosis row replay). | -| `src/lib/diagnosis-report.ts` | Renders the diagnosis results as a copy-pasteable GitHub-flavoured markdown table: a `## Truapi Diagnosis` title (host mode via `detectHostMode` — a native host (Electron UA or `__HOST_WEBVIEW_MARK__`) is split by user-agent into Desktop / Android / iOS, a browser iframe ⇒ Web) a `**N success · N failed**` summary line, and one row per method. Skipped methods are reported as failed (`❌`) with the skip reason in the Details column, so every truapi method stays in the compatibility matrix (the aggregator keeps only `✅`/`❌` cells). Deterministic for a given set of results (no timestamp). `renderReportMarkdown(…, { dropSuccessDetails })` emits a compact variant (success-row details blanked, failures kept) for the length-limited issue URL; `reportIssueUrl` caps the whole URL and falls back to a "paste from clipboard" body when a report is still too large. Consumed by the explorer's matrix aggregator. | +| `src/lib/diagnosis-report.ts` | Adapts App results to the shared deterministic Markdown formatter and adds host-mode detection and issue submission. | +| `shared/diagnosis.ts` | Framework-independent diagnosis result model and Markdown formatter shared by the App and Chat executables. | +| `worker/index.ts` | Coordinates the native Chat diagnosis over the generated Chat API. | +| `worker/diagnosis.ts` | Owns ordered Chat-only result state and renders both Markdown and native custom-renderer trees. | | `src/lib/host-api-bridge.ts` | Just `stringify`, the JSON-with-bigint helper shared across components. | | `src/components/ExampleEditor.tsx` | Monaco editor wrapper. Auto-folds `// #region helpers` blocks on mount. | | `src/components/MethodView.tsx` | Per-method view: signature link to cargo doc, Example / Output tabs, status LED, Run / Stop buttons. Output is the example's `console.*` log; an explicit `assert`/error throw flips the LED to error and shows the thrown message. | @@ -54,7 +58,7 @@ The Diagnosis screen emits a per-host markdown report via "Copy report". Aggrega ### Source of Truth for Methods -`@parity/truapi/playground/services` is generated from the truapi crate's rustdoc JSON. Each method entry carries: +`@parity/truapi/playground/services` is generated from the truapi crate's rustdoc JSON. Each service may carry `requiredExecution`, and each method entry carries: - `name`, `type` (`"unary"` or `"subscription"`) - `signature`: the TS-shaped method signature shown in the API panel diff --git a/playground/README.md b/playground/README.md index 4cf9ea38..27f9f457 100644 --- a/playground/README.md +++ b/playground/README.md @@ -1,24 +1,24 @@ # TrUAPI Playground -_Browse, edit, and call every TrUAPI method live against a connected Polkadot host._ +_Browse, edit, and call App-compatible TrUAPI methods live against a connected Polkadot host._ -The playground is an interactive reference for the TrUAPI: every method grouped by domain, with live request payload editing, one-click calls, and live subscriptions. It must be opened from inside a TrUAPI host so it can talk to the host over the wire. +The playground is an interactive reference for the App-compatible TrUAPI surface: methods are grouped by domain, with live request payload editing, one-click calls, and live subscriptions. It must be opened from inside a TrUAPI host so it can talk to the host over the wire. **Live app:** [https://truapi-playground.dot.li/](https://truapi-playground.dot.li/) ## Features -- **Full method browser**: every TrUAPI service and method, each with a description and a Request / Response or Subscription badge. +- **Execution-aware method browser**: every TrUAPI service available to an `App` execution, each with a description and a Request / Response or Subscription badge. - **Live calls**: edit a JSON request payload and fire the call against the connected host. - **Subscriptions**: open and close streaming methods and watch events arrive in real time. -- **Auto-test view**: runs every method and reports pass / fail in one pass. -- **Diagnosis view**: runs the full surface and produces a copy-pasteable markdown report per host. The explorer's Compatibility page aggregates those into a cross-host matrix. See [Diagnosis](#diagnosis). +- **Auto-test view**: runs every listed method and reports pass / fail in one pass. +- **Diagnosis view**: runs the App surface and produces a copy-pasteable markdown report per host. The explorer's Compatibility page aggregates those into a cross-host matrix. See [Diagnosis](#diagnosis). - **Wiring status**: methods that are not yet bound are flagged "Not supported" so you can see protocol coverage at a glance. -- **Chat worker**: the same build emits `out/worker/index.js`, which exercises - every generated Chat method. It verifies room creation, the intentionally - unavailable bot registration call, room-list updates, text and custom - messages, actions, and the paired custom-renderer streams before answering - `!echo `. +- **Chat diagnosis**: the same build emits `out/worker/index.js`, a native Chat + application that tests room creation and idempotency, live room-list updates, + text and custom messages, user actions, and custom-renderer channels. It + displays live results in Chat and posts a Chat-only Markdown report after + `!diagnose` completes the action check. ## Local development @@ -39,6 +39,11 @@ The app needs a host to connect to. Opening it directly in a regular browser wil at `out/worker/index.js`. Both resolve `@parity/truapi` from the linked workspace package in `../js/packages/truapi`. +The browser and Chat diagnoses are intentionally separate. Generated service +metadata carries `requiredExecution`; the browser omits services requiring +`Chat`, while the worker tests only the Chat service in its trusted Chat +connection. + ## Adding a method Methods reach the playground via codegen — there is no per-method wiring file to edit. The flow: @@ -60,7 +65,19 @@ An example **passes** when its promise resolves and **fails** when it throws. Us ## Diagnosis -The Diagnosis view exercises every TrUAPI method against the connected host and emits a per-host pass/fail report you can copy out. Per-host reports feed the explorer's **Compatibility** page, which renders the host × method matrix; aggregation lives in the explorer (see [`explorer/README.md`](../explorer/README.md#host-compatibility-matrix)). +The Diagnosis view exercises every App-compatible TrUAPI method against the connected host and emits a per-host pass/fail report you can copy out. Per-host reports feed the explorer's **Compatibility** page, which renders the host × method matrix; aggregation lives in the explorer (see [`explorer/README.md`](../explorer/README.md#host-compatibility-matrix)). Chat APIs are diagnosed separately by the native Chat worker. + +Run the iOS Chat diagnosis from the repository root: + +```bash +make ios-chat-run +``` + +It writes a Chat-only report to +`playground/test-results/ios-chat/diagnosis-report.md`. The native diagnosis +widget also provides **Copy report**; save the result as +`explorer/diagnosis-reports/chat/ios.md` to update the explorer's separate Chat +compatibility section. Open the playground inside a TrUAPI host (it cannot run standalone in a browser tab): @@ -78,7 +95,7 @@ Then, in the playground: 2. Read the instructions on the screen, then click **Run diagnosis**. 3. Wait for the run to finish. Non-disruptive methods run in parallel first, then disruptive methods run one at a time — approve each pop-up on your phone as it appears. A live log updates per method (`queued → processing… → success / failed`). 4. When the run finishes, a **Report** panel appears above the log. Click **Copy report**. -5. Click **Submit report ↗** to file a pre-filled GitHub issue that the `diagnosis-report` workflow turns into a per-host PR under `explorer/diagnosis-reports/`. (Or click **Copy report**, save the markdown to a host-named file like `web.md`, and update the matrix by hand — see [`../explorer/README.md`](../explorer/README.md#updating-the-matrix).) +5. Click **Submit report ↗** to file a pre-filled GitHub issue that the `diagnosis-report` workflow turns into a per-host PR under `explorer/diagnosis-reports/`. (Or click **Copy report**, save the markdown to a host-named file like `spa/web.md`, and update the matrix by hand — see [`../explorer/README.md`](../explorer/README.md#updating-the-matrix).) The report looks like this: diff --git a/playground/package.json b/playground/package.json index 876887a2..20d7628c 100644 --- a/playground/package.json +++ b/playground/package.json @@ -16,6 +16,7 @@ "typecheck:examples": "tsc -p tsconfig.examples.json", "deploy:test": "yarn build && bulletin-deploy ./out truapi-playground.dot && rm out.car", "lint:fix": "next lint --fix", + "test:unit": "bun test tests/unit", "e2e": "playwright test", "e2e:headed": "playwright test --headed", "e2e:ui": "playwright test --ui" diff --git a/playground/shared/diagnosis.ts b/playground/shared/diagnosis.ts new file mode 100644 index 00000000..03bb8905 --- /dev/null +++ b/playground/shared/diagnosis.ts @@ -0,0 +1,50 @@ +export type DiagnosisStatus = "idle" | "running" | "pass" | "fail" | "skipped"; + +export interface DiagnosisResult { + id: string; + status: DiagnosisStatus; + details?: string; +} + +const ICON: Record = { + pass: "✅", + fail: "❌", + skipped: "⏭", + idle: "·", + running: "↻", +}; + +/** Render ordered diagnosis results as deterministic GitHub Markdown. */ +export function renderDiagnosisMarkdown( + results: DiagnosisResult[], + options: { title: string; dropSuccessDetails?: boolean }, +): string { + let pass = 0; + let fail = 0; + const rows: string[] = []; + + for (const result of results) { + const reportStatus = result.status === "skipped" ? "fail" : result.status; + if (reportStatus === "pass") pass++; + else if (reportStatus === "fail") fail++; + const details = + options.dropSuccessDetails && reportStatus === "pass" + ? "" + : escapeTableCell(result.details); + rows.push(`| \`${result.id}\` | ${ICON[reportStatus]} | ${details} |`); + } + + return [ + `## ${options.title}`, + "", + `**${pass} success · ${fail} failed**`, + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + ...rows, + ].join("\n"); +} + +function escapeTableCell(value: string | undefined): string { + return value?.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim() ?? ""; +} diff --git a/playground/src/components/DiagnosisView.tsx b/playground/src/components/DiagnosisView.tsx index 520bc3f2..b40b5b53 100644 --- a/playground/src/components/DiagnosisView.tsx +++ b/playground/src/components/DiagnosisView.tsx @@ -99,7 +99,8 @@ export function DiagnosisView({ }; // Open a pre-filled GitHub issue carrying the report; the diagnosis-report - // workflow writes it to diagnosis-reports/.md and opens a PR. The host + // workflow writes it to diagnosis-reports/spa/.md (or chat/.md + // for Chat reports) and opens a PR. The host // opens the link via `navigate_to` (a sandboxed app can't `window.open`). // The full report goes to the clipboard (lossless fallback); the URL carries // a compact variant with success-row details dropped, so it stays under @@ -137,12 +138,13 @@ export function DiagnosisView({ About

- Runs every TrUAPI method against the connected host to build a - coverage report — which methods work, which fail, and which - aren't wired yet. Methods run one at a time, in order; those that - need your approval (signing, permission and resource requests) wait on - your response before the run continues. When it finishes, copy the - report below. + Runs every App-compatible TrUAPI method against the connected host to + build a coverage report — which methods work, which fail, and which + aren't wired yet. Chat APIs run in the separate native Chat + diagnosis. Methods run one at a time, in order; those that need your + approval (signing, permission and resource requests) wait on your + response before the run continues. When it finishes, copy the report + below.

Before you start: make sure you are logged in, and diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index d02fa134..b27cd6ba 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -1,10 +1,11 @@ import { runExample, type LogEntry, type RunResult } from "./example-runner"; import { getClientSync } from "@parity/truapi/sandbox"; import type { MethodInfo, ServiceInfo } from "./services"; +import type { DiagnosisStatus } from "@/shared/diagnosis"; export const DIAGNOSIS_ID = "__diagnosis__"; -export type TestStatus = "idle" | "running" | "pass" | "fail" | "skipped"; +export type TestStatus = DiagnosisStatus; export interface TestEntry { status: TestStatus; @@ -20,7 +21,7 @@ const SSO_TIMEOUT_MS = 60_000; const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; // Services skipped wholesale in the diagnosis until hosts wire them up. -const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +const SKIPPED_SERVICES = new Set(["Coin Payment", "Payment"]); // Methods that trigger a host permission/signing prompt, so they need the // longer signing-class timeout to allow for the user to respond. const LONG_TIMEOUT_METHODS = new Set([ diff --git a/playground/src/lib/diagnosis-report.ts b/playground/src/lib/diagnosis-report.ts index 8dfb4d2c..fd984d99 100644 --- a/playground/src/lib/diagnosis-report.ts +++ b/playground/src/lib/diagnosis-report.ts @@ -1,13 +1,6 @@ import type { ServiceInfo } from "./services"; -import type { TestEntry, TestStatus } from "./auto-test"; - -const ICON: Record = { - pass: "✅", - fail: "❌", - skipped: "⏭", - idle: "·", - running: "↻", -}; +import type { TestEntry } from "./auto-test"; +import { renderDiagnosisMarkdown } from "@/shared/diagnosis"; export type HostMode = "Web" | "Desktop" | "Android" | "iOS" | "Unknown"; @@ -47,46 +40,19 @@ export function renderReportMarkdown( meta: { mode?: HostMode; dropSuccessDetails?: boolean } = {}, ): string { const mode = meta.mode ?? detectHostMode(); - let pass = 0; - let fail = 0; - const rows: string[] = []; + const rows = []; for (const svc of services) { for (const m of svc.methods) { const id = `${svc.name}/${m.name}`; const entry = results[id]; const status = entry?.status ?? "idle"; - // Skipped methods are reported as failed so every truapi method stays in - // the compatibility matrix (the aggregator keeps only ✅/❌ cells); the - // reason the method was skipped travels in the Details column. - const reportStatus = status === "skipped" ? "fail" : status; - if (reportStatus === "pass") pass++; - else if (reportStatus === "fail") fail++; - // The issue-URL variant drops success-row details (bulky response - // payloads) to keep the URL under GitHub's length limit; failures keep - // their (short) details. - const detail = - meta.dropSuccessDetails && reportStatus === "pass" - ? "" - : detailCell(entry); - rows.push(`| \`${id}\` | ${ICON[reportStatus]} | ${detail} |`); + rows.push({ id, status, details: entry?.output }); } } - - const lines: string[] = []; - lines.push(`## Truapi ${mode} Diagnosis`); - lines.push(""); - lines.push(`**${pass} success · ${fail} failed**`); - lines.push(""); - lines.push("| Method | Status | Details |"); - lines.push("| --- | --- | --- |"); - lines.push(...rows); - return lines.join("\n"); -} - -// Method output flattened to a single escaped table cell. -function detailCell(entry: TestEntry | undefined): string { - if (entry?.output == null) return ""; - return entry.output.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim(); + return renderDiagnosisMarkdown(rows, { + title: `Truapi ${mode} Diagnosis`, + dropSuccessDetails: meta.dropSuccessDetails, + }); } // Repo that receives the pre-filled diagnosis-report issues; the diff --git a/playground/src/lib/services.ts b/playground/src/lib/services.ts index 609b3b05..f954f6db 100644 --- a/playground/src/lib/services.ts +++ b/playground/src/lib/services.ts @@ -1,9 +1,15 @@ import { services as generatedServices } from "@parity/truapi/playground/services"; +import { servicesForExecution } from "@parity/truapi/playground/services-types"; import type { MethodInfo, + ProductExecutionKind, ServiceInfo, } from "@parity/truapi/playground/services-types"; -export type { MethodInfo, ServiceInfo }; +export type { MethodInfo, ProductExecutionKind, ServiceInfo }; +export { servicesForExecution }; -export const services: ServiceInfo[] = generatedServices; +export const services: ServiceInfo[] = servicesForExecution( + generatedServices, + "App", +); diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts index a7aa1387..902bb494 100644 --- a/playground/tests/e2e/dotli-diagnosis.ts +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -58,12 +58,7 @@ const signingHostConfig: SigningHostCliConfig = { }; const expectedHostGaps = [ "Account/create_account_proof", - "Chat/create_room", - "Chat/register_bot", - "Chat/list_subscribe", - "Chat/post_message", - "Chat/action_subscribe", - "Chat/custom_message_render_subscribe", + "Account/sign_vrf", "Coin Payment/create_purse", "Coin Payment/query_purse", "Coin Payment/rebalance_purse", @@ -313,6 +308,8 @@ function isRetryableSigningHostPairError(error: unknown): boolean { return ( message.includes("Invalid Transaction") || message.includes("temporarily banned") || + message.includes("did not appear in a LitePeople ring") || + message.includes("not a LitePeople ring member") || message.includes("timed out waiting for author_submitAndWatchExtrinsic") ); } diff --git a/playground/tests/unit/chat-diagnosis.test.ts b/playground/tests/unit/chat-diagnosis.test.ts new file mode 100644 index 00000000..10618e56 --- /dev/null +++ b/playground/tests/unit/chat-diagnosis.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { CHAT_DIAGNOSIS_METHODS, ChatDiagnosis } from "../../worker/diagnosis"; + +describe("ChatDiagnosis", () => { + test("keeps Chat methods ordered and renders a Chat-only report", () => { + const diagnosis = new ChatDiagnosis(); + for (const id of CHAT_DIAGNOSIS_METHODS) { + diagnosis.pass(id, "worked"); + } + + expect(diagnosis.isComplete()).toBe(true); + expect(diagnosis.markdown()).toContain("## Truapi Chat Diagnosis"); + expect(diagnosis.markdown()).toContain("**5 success · 0 failed**"); + expect(diagnosis.markdown()).not.toContain("Storage/"); + }); + + test("reports failures without preventing renderer output", () => { + const diagnosis = new ChatDiagnosis(); + diagnosis.fail("Chat/create_room", new Error("room unavailable")); + diagnosis.pass("Chat/create_room", "late success"); + + expect(diagnosis.markdown()).toContain("❌ | room unavailable"); + expect(diagnosis.markdown()).not.toContain("late success"); + expect(diagnosis.rendererNode().tag).toBe("Column"); + }); + + test("renders a copy action and reports clipboard fallback state", () => { + const diagnosis = new ChatDiagnosis(); + const initial = JSON.stringify(diagnosis.rendererNode()); + + expect(initial).toContain("Copy report"); + expect(initial).toContain("truapi-chat-diagnosis-copy"); + + diagnosis.copyUnavailable(); + expect(JSON.stringify(diagnosis.rendererNode())).toContain( + "long-press the report message below", + ); + + diagnosis.copied(); + expect(JSON.stringify(diagnosis.rendererNode())).toContain("Copied ✓"); + }); +}); diff --git a/playground/tests/unit/diagnosis.test.ts b/playground/tests/unit/diagnosis.test.ts new file mode 100644 index 00000000..4d5cdd4a --- /dev/null +++ b/playground/tests/unit/diagnosis.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { renderDiagnosisMarkdown } from "../../shared/diagnosis"; + +describe("renderDiagnosisMarkdown", () => { + test("renders deterministic ordered rows and escapes details", () => { + expect( + renderDiagnosisMarkdown( + [ + { id: "Chat/create_room", status: "pass", details: "New | Exists" }, + { id: "Chat/post_message", status: "fail", details: "not stored" }, + ], + { title: "Truapi iOS Chat Diagnosis" }, + ), + ).toBe( + [ + "## Truapi iOS Chat Diagnosis", + "", + "**1 success · 1 failed**", + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + "| `Chat/create_room` | ✅ | New \\| Exists |", + "| `Chat/post_message` | ❌ | not stored |", + ].join("\n"), + ); + }); + + test("drops only successful details for compact reports", () => { + const report = renderDiagnosisMarkdown( + [ + { id: "Chat/create_room", status: "pass", details: "created" }, + { id: "Chat/post_message", status: "fail", details: "failed" }, + ], + { title: "Truapi Chat Diagnosis", dropSuccessDetails: true }, + ); + + expect(report).toContain("| `Chat/create_room` | ✅ | |"); + expect(report).toContain("| `Chat/post_message` | ❌ | failed |"); + }); +}); diff --git a/playground/worker/diagnosis.ts b/playground/worker/diagnosis.ts new file mode 100644 index 00000000..21bc8553 --- /dev/null +++ b/playground/worker/diagnosis.ts @@ -0,0 +1,163 @@ +import type { CustomRendererNode } from "@parity/truapi"; +import { + renderDiagnosisMarkdown, + type DiagnosisResult, +} from "../shared/diagnosis"; + +export const CHAT_DIAGNOSIS_METHODS = [ + "Chat/create_room", + "Chat/list_subscribe", + "Chat/post_message", + "Chat/action_subscribe", + "Chat/custom_message_render_channel", +] as const; + +export type ChatDiagnosisMethod = (typeof CHAT_DIAGNOSIS_METHODS)[number]; + +type CopyStatus = "idle" | "copied" | "unavailable"; + +const STATUS_ICON = { + idle: "·", + running: "↻", + pass: "✓", + fail: "✕", + skipped: "–", +} as const; + +export class ChatDiagnosis { + readonly #results = new Map(); + readonly #onChange: () => void; + #copyStatus: CopyStatus = "idle"; + + constructor(onChange: () => void = () => {}) { + this.#onChange = onChange; + for (const id of CHAT_DIAGNOSIS_METHODS) { + this.#results.set(id, { id, status: "running" }); + } + } + + pass(id: ChatDiagnosisMethod, details: string): void { + if (this.#results.get(id)?.status === "fail") return; + this.#results.set(id, { id, status: "pass", details }); + this.#onChange(); + } + + fail(id: ChatDiagnosisMethod, error: unknown): void { + const details = error instanceof Error ? error.message : String(error); + this.#results.set(id, { id, status: "fail", details }); + this.#onChange(); + } + + failPending(error: unknown): void { + const details = error instanceof Error ? error.message : String(error); + for (const id of CHAT_DIAGNOSIS_METHODS) { + if (this.#results.get(id)?.status === "running") { + this.#results.set(id, { id, status: "fail", details }); + } + } + this.#onChange(); + } + + results(): DiagnosisResult[] { + return CHAT_DIAGNOSIS_METHODS.map((id) => ({ ...this.#results.get(id)! })); + } + + isComplete(): boolean { + return this.results().every( + ({ status }) => status === "pass" || status === "fail", + ); + } + + markdown(): string { + return renderDiagnosisMarkdown(this.results(), { + title: "Truapi Chat Diagnosis", + }); + } + + copied(): void { + this.#copyStatus = "copied"; + this.#onChange(); + } + + copyUnavailable(): void { + this.#copyStatus = "unavailable"; + this.#onChange(); + } + + rendererNode(): CustomRendererNode { + const results = this.results(); + const passed = results.filter(({ status }) => status === "pass").length; + const failed = results.filter(({ status }) => status === "fail").length; + return column([ + text("TrUAPI Chat Diagnosis", "HeadlineLarge"), + text(`${passed} success · ${failed} failed`, "BodyMediumRegular"), + ...results.map((result) => + text( + `${STATUS_ICON[result.status]} ${result.id.replace("Chat/", "")}`, + "BodySmallRegular", + ), + ), + { + tag: "Button", + value: { + modifiers: [], + props: { + text: "Refresh results", + variant: "Secondary", + enabled: true, + loading: false, + clickAction: "truapi-chat-diagnosis-refresh", + }, + children: [], + }, + }, + { + tag: "Button", + value: { + modifiers: [], + props: { + text: this.#copyStatus === "copied" ? "Copied ✓" : "Copy report", + variant: "Secondary", + enabled: true, + loading: false, + clickAction: "truapi-chat-diagnosis-copy", + }, + children: [], + }, + }, + ...(this.#copyStatus === "unavailable" + ? [ + text( + "Clipboard unavailable; long-press the report message below to copy it.", + "BodySmallRegular", + ), + ] + : []), + ]); + } +} + +function column(children: CustomRendererNode[]): CustomRendererNode { + return { + tag: "Column", + value: { + modifiers: [], + props: { horizontalAlignment: "Start", verticalArrangement: "Start" }, + children, + }, + }; +} + +function text( + value: string, + style: "HeadlineLarge" | "BodyMediumRegular" | "BodySmallRegular", +): CustomRendererNode { + return { + tag: "Text", + value: { + modifiers: [], + props: { style }, + children: [{ tag: "String", value: { text: value } }], + }, + }; +} diff --git a/playground/worker/index.ts b/playground/worker/index.ts index 18ec9bf8..806594af 100644 --- a/playground/worker/index.ts +++ b/playground/worker/index.ts @@ -2,160 +2,234 @@ import { getClientSync } from "@parity/truapi/sandbox"; import type { HostChatActionSubscribeItem, HostChatListSubscribeItem, + HexString, ObservableLike, - ProductChatCustomMessageRenderSubscribeItem, + ProductChatCustomMessageRenderChannelItem, + ProductChatCustomMessageRenderChannelRequest, + Subscription, } from "@parity/truapi"; +import { Subject } from "rxjs"; +import { ChatDiagnosis } from "./diagnosis"; const ROOM_ID = "truapi-playground"; const ROOM_NAME = "TrUAPI Playground"; -const BOT_ID = "truapi-playground-bot"; +const DIAGNOSIS_COMMAND = "!diagnose"; const ECHO_COMMAND = "!echo"; -const RENDER_MESSAGE_TYPE = "truapi-chat-api-check"; -const RENDER_PAYLOAD = - "0x637573746f6d5f6d6573736167655f72656e6465725f737562736372696265"; +const RENDER_MESSAGE_TYPE = "truapi-chat-diagnosis"; +const REFRESH_ACTION = "truapi-chat-diagnosis-refresh"; +const COPY_ACTION = "truapi-chat-diagnosis-copy"; +const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const diagnosticRoomId = `${ROOM_ID}-diagnosis-${runId}`; +const renderPayload = encodeHex(JSON.stringify({ version: 1, runId })); const client = getClientSync(); if (!client) { throw new Error("TrUAPI Playground Chat worker requires a host connection"); } const chat = client.chat; +let customMessageId: string | undefined; +let finalReportPosted = false; +const activeRenderMessageIds = new Set(); +const pendingRenderRequests: ProductChatCustomMessageRenderChannelItem[] = []; -const room = await chat.createRoom({ - roomId: ROOM_ID, - name: ROOM_NAME, - icon: "", +const diagnosis = new ChatDiagnosis(() => { + renderActiveMessages(); + void publishFinalReportIfComplete(); }); -if (room.isErr()) { - throw new Error( - `Unable to create the Playground Chat room: ${JSON.stringify(room.error)}`, - ); -} - -const bot = await chat.registerBot({ - botId: BOT_ID, - name: ROOM_NAME, - icon: "", -}); -if ( - bot.isOk() || - bot.error.tag !== "HostFailure" || - bot.error.value.reason !== "unavailable" -) { - throw new Error( - `registerBot no longer has the expected unavailable behavior: ${JSON.stringify(bot)}`, - ); -} -const renderer = chat.customMessageRenderSubscribe(); -renderer.responses.subscribe({ - next(item) { - handleRenderRequest(item); - }, +const renderRequests = + new Subject(); +chat.customMessageRenderChannel(renderRequests).subscribe({ + next: handleRenderRequest, error(error) { - console.error("TrUAPI Playground custom renderer stream failed", error); - }, -}); - -const rooms = chat.listSubscribe(); -await waitForPlaygroundRoom(rooms); - -await postMessage({ - tag: "Text", - value: { - text: 'Chat API checks passed. Send "!echo " to test actions.', - }, -}); -await postMessage({ - tag: "Custom", - value: { - messageType: RENDER_MESSAGE_TYPE, - payload: RENDER_PAYLOAD, + diagnosis.fail("Chat/custom_message_render_channel", error); }, }); chat.actionSubscribe().subscribe({ next(action) { void handleAction(action).catch((error: unknown) => { - console.error("Unable to handle the Playground Chat action", error); + diagnosis.fail("Chat/action_subscribe", error); }); }, error(error) { - console.error("TrUAPI Playground Chat action stream failed", error); + diagnosis.fail("Chat/action_subscribe", error); }, }); +await runStartupDiagnosis().catch((error: unknown) => { + diagnosis.failPending(error); + console.error( + "TrUAPI Playground Chat diagnosis failed", + error instanceof Error ? error.message : String(error), + ); +}); + +async function runStartupDiagnosis(): Promise { + await ensureRoom(ROOM_ID, ROOM_NAME); + + const roomAppeared = waitForRoom(chat.listSubscribe(), diagnosticRoomId); + const first = await chat.createRoom({ + roomId: diagnosticRoomId, + name: `TrUAPI Diagnosis ${runId}`, + icon: "", + }); + if (first.isErr()) { + throw new Error(`createRoom failed: ${JSON.stringify(first.error)}`); + } + if (first.value.status !== "New") { + throw new Error( + `first createRoom returned ${first.value.status}, expected New`, + ); + } + + const second = await chat.createRoom({ + roomId: diagnosticRoomId, + name: `TrUAPI Diagnosis ${runId}`, + icon: "", + }); + if (second.isErr()) { + throw new Error( + `second createRoom failed: ${JSON.stringify(second.error)}`, + ); + } + if (second.value.status !== "Exists") { + throw new Error( + `second createRoom returned ${second.value.status}, expected Exists`, + ); + } + diagnosis.pass("Chat/create_room", "created once, then returned Exists"); + + await roomAppeared; + diagnosis.pass("Chat/list_subscribe", "observed the newly created room"); + + const textMessageId = await postMessage({ + tag: "Text", + value: { + text: `Chat diagnosis ${runId} started. Send "${DIAGNOSIS_COMMAND}" to test actions.`, + }, + }); + customMessageId = await postMessage({ + tag: "Custom", + value: { + messageType: RENDER_MESSAGE_TYPE, + payload: renderPayload, + }, + }); + for (const item of pendingRenderRequests.splice(0)) { + handleRenderRequest(item); + } + if (!textMessageId || !customMessageId || textMessageId === customMessageId) { + throw new Error("postMessage did not return distinct message identifiers"); + } + diagnosis.pass("Chat/post_message", "posted text and custom messages"); +} + +async function ensureRoom(roomId: string, name: string): Promise { + const result = await chat.createRoom({ roomId, name, icon: "" }); + if (result.isErr()) { + throw new Error( + `Unable to create the Playground room: ${JSON.stringify(result.error)}`, + ); + } +} + function handleRenderRequest( - item: ProductChatCustomMessageRenderSubscribeItem, + item: ProductChatCustomMessageRenderChannelItem, ): void { if (item.messageType !== RENDER_MESSAGE_TYPE) { - renderer.requests.next({ + renderRequests.next({ tag: "Failed", value: { messageId: item.messageId }, }); return; } + try { + const payload = JSON.parse(decodeHex(item.payload)) as { + version?: number; + runId?: string; + }; - renderer.requests.next({ - tag: "Update", - value: { - messageId: item.messageId, - node: { - tag: "Text", - value: { - modifiers: [], - props: {}, - children: [ - { - tag: "String", - value: { - text: `${decodeHex(item.payload)}: passed`, - }, - }, - ], - }, - }, - }, - }); -} + // Native Chat can restore custom messages from an earlier worker run before + // it asks the current run to render its own message. Those requests belong + // to renderer state that no longer exists, so reject them without turning + // the current diagnosis red. + if (payload.runId !== runId) { + renderRequests.next({ + tag: "Failed", + value: { messageId: item.messageId }, + }); + return; + } + if (payload.version !== 1) { + throw new Error("render request did not preserve the custom payload"); + } + if (!customMessageId) { + pendingRenderRequests.push(item); + return; + } + if (item.messageId !== customMessageId) { + throw new Error( + `render request message ${item.messageId} did not match ${customMessageId}`, + ); + } -async function waitForPlaygroundRoom( - observable: ObservableLike, -): Promise { - await new Promise((resolve, reject) => { - const subscription = observable.subscribe({ - next(item) { - if (item.rooms.some((candidate) => candidate.roomId === ROOM_ID)) { - subscription.unsubscribe(); - resolve(); - } - }, - error: reject, - complete() { - reject(new Error("Chat room list ended before returning the room")); - }, + activeRenderMessageIds.add(item.messageId); + renderRequests.next({ + tag: "Update", + value: { messageId: item.messageId, node: diagnosis.rendererNode() }, }); - }); + diagnosis.pass( + "Chat/custom_message_render_channel", + "correlated render work and sent initial and replacement trees", + ); + } catch (error) { + diagnosis.fail("Chat/custom_message_render_channel", error); + renderRequests.next({ + tag: "Failed", + value: { messageId: item.messageId }, + }); + } } -async function postMessage( - payload: Parameters[0]["payload"], -): Promise { - const result = await chat.postMessage({ roomId: ROOM_ID, payload }); - if (result.isErr()) { - throw new Error( - `Unable to post a Playground Chat message: ${JSON.stringify(result.error)}`, - ); +function renderActiveMessages(): void { + const node = diagnosis.rendererNode(); + for (const messageId of activeRenderMessageIds) { + renderRequests.next({ + tag: "Update", + value: { messageId, node }, + }); } - return result.value.messageId; } async function handleAction( action: HostChatActionSubscribeItem, ): Promise { + if (action.payload.tag === "ActionTriggered") { + const trigger = action.payload.value; + if (trigger.messageId === customMessageId) { + if (trigger.actionId === REFRESH_ACTION) { + renderActiveMessages(); + } else if (trigger.actionId === COPY_ACTION) { + await copyDiagnosisReport(); + } + } + return; + } if (action.payload.tag !== "MessagePosted") return; if (action.payload.value.tag !== "Text") return; const text = action.payload.value.value.text.trim(); + if (text === DIAGNOSIS_COMMAND) { + if (action.roomId !== ROOM_ID) { + throw new Error(`diagnosis command was delivered for ${action.roomId}`); + } + diagnosis.pass( + "Chat/action_subscribe", + "received MessagePosted with the originating room", + ); + return; + } if (!text.startsWith(ECHO_COMMAND)) return; const body = text.slice(ECHO_COMMAND.length).trim(); @@ -169,11 +243,95 @@ async function handleAction( }, }); if (result.isErr()) { - console.error("Unable to post the Playground Chat reply", result.error); + throw new Error( + `Unable to post the echo reply: ${JSON.stringify(result.error)}`, + ); + } +} + +async function copyDiagnosisReport(): Promise { + try { + if (!globalThis.navigator?.clipboard?.writeText) { + throw new Error("Clipboard API is unavailable"); + } + await globalThis.navigator.clipboard.writeText(diagnosis.markdown()); + diagnosis.copied(); + } catch { + // A standard native Chat text message already exposes the host's Copy menu, + // so keep that as a reliable fallback when the worker has no clipboard. + diagnosis.copyUnavailable(); + await postMessage({ + tag: "Text", + value: { text: diagnosis.markdown() }, + }); } } -function decodeHex(value: `0x${string}`): string { +async function publishFinalReportIfComplete(): Promise { + if (!diagnosis.isComplete() || finalReportPosted) return; + finalReportPosted = true; + const result = await chat.postMessage({ + roomId: ROOM_ID, + payload: { tag: "Text", value: { text: diagnosis.markdown() } }, + }); + if (result.isErr()) { + diagnosis.fail( + "Chat/post_message", + `Unable to post the final report: ${JSON.stringify(result.error)}`, + ); + } +} + +async function postMessage( + payload: Parameters[0]["payload"], +): Promise { + const result = await chat.postMessage({ roomId: ROOM_ID, payload }); + if (result.isErr()) { + throw new Error( + `Unable to post a Playground Chat message: ${JSON.stringify(result.error)}`, + ); + } + return result.value.messageId; +} + +async function waitForRoom( + observable: ObservableLike, + roomId: string, +): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timed out waiting for room ${roomId}`)); + }, 10_000); + const subscription: Subscription = observable.subscribe({ + next(item) { + if (item.rooms.some((candidate) => candidate.roomId === roomId)) { + queueMicrotask(() => { + clearTimeout(timeout); + subscription.unsubscribe(); + }); + resolve(); + } + }, + error(error) { + clearTimeout(timeout); + reject(error); + }, + complete() { + clearTimeout(timeout); + reject(new Error(`Chat room list ended before returning ${roomId}`)); + }, + }); + }); +} + +function encodeHex(value: string): HexString { + return `0x${Array.from(new TextEncoder().encode(value), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; +} + +function decodeHex(value: HexString): string { const bytes = value .slice(2) .match(/.{1,2}/g) diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 77b8e7a5..86b1ddc6 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -760,6 +760,7 @@ fn extract_method(item_id: &str, item: &Item, names: &NameContext) -> Result Result Result<()> { + if matches!(kind, MethodKind::StreamPair) && name.ends_with("_subscribe") { + let stem = name + .strip_suffix("_subscribe") + .expect("suffix checked above"); + bail!( + "Paired-stream method `{name}` must use the `_channel` suffix; rename it to `{stem}_channel`" + ); + } + Ok(()) +} + /// Strips hidden codegen marker lines from a rustdoc comment so it can be /// emitted as user-facing JSDoc. Returns `None` when the remaining text is /// empty. @@ -1507,6 +1520,26 @@ mod tests { assert_eq!(trait_def.public_docs().as_deref(), Some("Chat operations.")); } + #[test] + fn stream_pair_rejects_subscribe_suffix() { + let error = + validate_stream_method_name("custom_message_render_subscribe", MethodKind::StreamPair) + .expect_err("paired streams must not use the subscription suffix"); + + assert_eq!( + error.to_string(), + "Paired-stream method `custom_message_render_subscribe` must use the `_channel` suffix; rename it to `custom_message_render_channel`" + ); + } + + #[test] + fn stream_pair_accepts_channel_suffix() { + assert!( + validate_stream_method_name("custom_message_render_channel", MethodKind::StreamPair) + .is_ok() + ); + } + #[test] fn parse_accepts_tested_format_version() { let json = format!(r#"{{ "format_version": {MIN_FORMAT_VERSION}, "index": {{}} }}"#); diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 01629e5b..784f8e9f 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -936,12 +936,12 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) import * as S from '../scale.js'; import type {{ HexString }} from '../scale.js'; import {{ SubscriptionError }} from '../transport.js'; - import type {{ ObservableLike, Observer, SubjectLike, Subscription, SubscriptionFrameIds, TrUApiTransport }} from '../transport.js'; + import type {{ ObservableLike, ObservableSource, Observer, Subscription, SubscriptionFrameIds, TrUApiTransport }} from '../transport.js'; import * as T from './types.js'; import * as W from './wire-table.js'; export {{ ResultAsync, SubscriptionError }}; - export type {{ ObservableLike, Observer, Result, SubjectLike, Subscription, TrUApiTransport }}; + export type {{ ObservableLike, ObservableSource, Observer, Result, Subscription, TrUApiTransport }}; export const TRUAPI_VERSION = {target_version} as const; export const TRUAPI_CODEC_VERSION = {codec_version} as const; @@ -1147,70 +1147,81 @@ fn write_observable_helper(out: &mut String) { fn write_stream_pair_helper(out: &mut String) { out.push_str(indoc! { r#" - function createStreamPair({ + function createStreamChannel({ transport, ids, payload, + requests, encodeRequest, decodeItem, }: { transport: TrUApiTransport; ids: SubscriptionFrameIds; payload: Uint8Array; + requests: ObservableSource; encodeRequest: (request: Request) => Uint8Array; decodeItem: (payload: Uint8Array) => Item; - }): { requests: SubjectLike; responses: ObservableLike } { - let active: Subscription | undefined; + }): ObservableLike { + let used = false; const source = createObservable({ transport, ids, payload, decodeItem }); - const responses: ObservableLike = { + const items: ObservableLike = { subscribe(observer: Partial> = {}): Subscription { - if (active) throw new Error("paired stream already has an active subscriber"); + if (used) throw new Error("channel is single-use: its one subscription is the operation"); + used = true; + let requestSubscription: { unsubscribe(): void } | undefined; let terminated = false; + const stopForwarding = () => { + const forwarding = requestSubscription; + requestSubscription = undefined; + forwarding?.unsubscribe(); + }; const subscription = source.subscribe({ next(value) { observer.next?.(value); }, error(error) { terminated = true; - active = undefined; + stopForwarding(); observer.error?.(error); }, complete() { terminated = true; - active = undefined; + stopForwarding(); observer.complete?.(); }, }); - if (!terminated) active = subscription; + if (!terminated) { + try { + requestSubscription = requests.subscribe({ + next(request) { + transport.sendSubscriptionItem({ + ids, + subscriptionId: subscription.subscriptionId, + payload: encodeRequest(request), + }); + }, + }); + } catch (error) { + subscription.unsubscribe(); + throw error; + } + if (terminated) stopForwarding(); + } return { get subscriptionId() { return subscription.subscriptionId; }, unsubscribe() { + stopForwarding(); subscription.unsubscribe(); - if (active === subscription) active = undefined; }, }; }, [OBSERVABLE_INTEROP as typeof Symbol.observable]() { - return responses; + return items; }, }; - return { - requests: { - next(request) { - if (!active?.subscriptionId) { - throw new Error("subscribe to responses before sending paired-stream requests"); - } - transport.sendSubscriptionItem({ - ids, - subscriptionId: active.subscriptionId, - payload: encodeRequest(request), - }); - }, - }, - responses, - }; + return items; } "# @@ -1647,13 +1658,13 @@ fn emit_stream_pair_method( writedoc!( out, " - {ts_method_name}(): {{ - requests: SubjectLike<{request_type}>; - responses: ObservableLike<{response_type}>; - }} {{ - return createStreamPair<{request_type}, {response_type}>({{ + {ts_method_name}( + requests: ObservableSource<{request_type}>, + ): ObservableLike<{response_type}> {{ + return createStreamChannel<{request_type}, {response_type}>({{ transport: this.transport, ids: W.{wire_const}, + requests, ", request_type = request.inner_type_ts, response_type = response.inner_type_ts, @@ -3010,13 +3021,13 @@ mod tests { } #[test] - fn generate_client_emits_subject_and_observable_for_stream_pair() { + fn generate_client_emits_channel_for_stream_pair() { let api = ApiDefinition { traits: vec![TraitDef { name: "Chat".to_string(), module_path: Vec::new(), methods: vec![stream_pair_method_with_wrappers( - "custom_message_render_subscribe", + "custom_message_render_channel", Some(52), "RendererRequest", "RendererItem", @@ -3032,9 +3043,11 @@ mod tests { let source = generate_client(&api, 1, 1).expect("generate paired-stream client"); - assert!(source.contains("requests: SubjectLike")); - assert!(source.contains("responses: ObservableLike")); - assert!(source.contains("return createStreamPair")); + assert!(source.contains("requests: ObservableSource")); + assert!(source.contains("): ObservableLike")); + assert!( + source.contains("return createStreamChannel") + ); assert!(source.contains("transport.sendSubscriptionItem({")); } diff --git a/rust/crates/truapi-codegen/src/ts/playground.rs b/rust/crates/truapi-codegen/src/ts/playground.rs index 3b02c272..53c8afed 100644 --- a/rust/crates/truapi-codegen/src/ts/playground.rs +++ b/rust/crates/truapi-codegen/src/ts/playground.rs @@ -57,11 +57,19 @@ fn generate_playground_services_code( " {{ name: {name}, - methods: [ ", name = ts_string_literal(&service_display_name(trait_def)), ) .unwrap(); + if let Some(required_execution) = trait_def.required_execution() { + writeln!( + out, + " requiredExecution: {},", + ts_string_literal(required_execution) + ) + .unwrap(); + } + writeln!(out, " methods: [").unwrap(); for method in methods { let wire_version = method_wire_version(method, &wrappers, target_version)?; diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index da5423f9..91d78569 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -765,15 +765,15 @@ where { let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host; - dispatcher.on_stream_pair(wire_table::CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE, move |request_id: String, bytes: Vec, requests| { + dispatcher.on_stream_pair(wire_table::CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL, move |request_id: String, bytes: Vec, requests| { let host = host.clone(); Box::pin(async move { let _ = bytes; let cx = CallContext::with_request_id(request_id.clone()); if !execution_allowed { return Err(Vec::new()); } - let requests = subscription_request_stream::(requests); - let stream = host.custom_message_render_subscribe(&cx, requests).await; - Ok(subscription_stream::(stream)) + let requests = subscription_request_stream::(requests); + let stream = host.custom_message_render_channel(&cx, requests).await; + Ok(subscription_stream::(stream)) }) }); } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index dca4cc44..fd2355d6 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -657,7 +657,8 @@ export interface ChainProvider { } /** - * Optional native chat storage and UI adapter used by Chat executions. + * Host-implemented adapter through which product Chat calls reach native + * storage and UI. */ export interface ChatPlatform { /** diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 7360d042..d5f78d3d 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -190,8 +190,8 @@ pub const CHAT_ACTION_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { receive_id: 51, }; -/// Wire discriminants for `chat_custom_message_render_subscribe`. -pub const CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { +/// Wire discriminants for `chat_custom_message_render_channel`. +pub const CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL: SubscriptionFrameIds = SubscriptionFrameIds { start_id: 52, stop_id: 53, interrupt_id: 54, @@ -562,8 +562,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), }, WireEntry { - method: "chat_custom_message_render_subscribe", - kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE), + method: "chat_custom_message_render_channel", + kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL), }, WireEntry { method: "statement_store_subscribe", diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 87bf209d..04d3a9de 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -245,7 +245,7 @@ Five scripts ship under `js/scripts/`: attempts all examples (including APIs the browser diagnosis classifies as intentionally unsupported), prints test-reporter rows with timings and clean failure details, writes the browser-shaped result matrix to - the role-specific report under `explorer/diagnosis-reports/`, and exits + the role-specific report under `explorer/diagnosis-reports/spa/`, and exits nonzero if any example fails. A paired run writes `pairing-host-cli.md`; a direct signing-host run writes `signing-host-cli.md`. Override the artifact path with `TRUAPI_BATTERY_REPORT_PATH`. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index d3584ffb..ded669f6 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -667,7 +667,7 @@ The top-level `--script` option does not update remembered `/script` state. | `ring-vrf-smoke.ts` | Verify alias/proof behavior for the Paseo Next v2 LitePeople ring. | | `preimage-smoke.ts` | Exercise Bulletin preimage submission and lookup. | -`battery.ts` writes to `explorer/diagnosis-reports/-cli.md` unless +`battery.ts` writes to `explorer/diagnosis-reports/spa/-cli.md` unless `TRUAPI_BATTERY_REPORT_PATH` overrides the destination. `scripts/battery.sh` in the repository root produces both reports in one invocation: it runs the direct signing-host phase, then starts a pairing host and answers its emitted link @@ -1535,8 +1535,8 @@ The implementation is covered by: - script-runner/Bun diagnosis tests; - paired and direct `battery.ts` runs, both driven by `scripts/battery.sh`; and - checked-in compatibility reports: - - `explorer/diagnosis-reports/pairing-host-cli.md` - - `explorer/diagnosis-reports/signing-host-cli.md` + - `explorer/diagnosis-reports/spa/pairing-host-cli.md` + - `explorer/diagnosis-reports/spa/signing-host-cli.md` The reports currently have identical method results apart from their title: diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index 63663b71..64b18962 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { services } from "../../../../js/packages/truapi/src/playground/codegen/services.ts"; +import { servicesForExecution } from "../../../../js/packages/truapi/src/playground/services-types.ts"; import { BatteryReporter, shouldUseColor } from "./battery-reporter.ts"; import { cliDiagnosisReportMetadata, @@ -33,8 +34,9 @@ describe("generated-example battery", () => { }); test("derives every case from the generated playground manifest", () => { - const generatedIds = services.flatMap((service) => - service.methods.map((method) => `${service.name}/${method.name}`), + const generatedIds = servicesForExecution(services, "App").flatMap( + (service) => + service.methods.map((method) => `${service.name}/${method.name}`), ); const plan = createDiagnosisPlan({ runKnownUnsupported: true }); diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts index cd9d10b0..f50c6c40 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -8,6 +8,7 @@ import { type LogEntry, } from "../../../../playground/src/lib/example-runner.ts"; import { services } from "../../../../js/packages/truapi/src/playground/codegen/services.ts"; +import { servicesForExecution } from "../../../../js/packages/truapi/src/playground/services-types.ts"; import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; // Starts from the playground diagnosis policy. The headless transport handles @@ -17,7 +18,8 @@ import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; const UNARY_TIMEOUT_MS = 10_000; const REMOTE_RESPONSE_TIMEOUT_MS = 190_000; const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; -const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +const APP_SERVICES = servicesForExecution(services, "App"); +const SKIPPED_SERVICES = new Set(["Coin Payment", "Payment"]); const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); const LONG_TIMEOUT_METHODS = new Set([ "Account/get_account", @@ -72,7 +74,7 @@ export interface DiagnosisOptions { export function createDiagnosisPlan( options: Pick = {}, ): DiagnosisCase[] { - return services.flatMap((service) => + return APP_SERVICES.flatMap((service) => service.methods.map((method) => { const id = `${service.name}/${method.name}`; return { diff --git a/rust/crates/truapi-host-cli/js/scripts/battery.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts index 1241ea02..26afde98 100644 --- a/rust/crates/truapi-host-cli/js/scripts/battery.ts +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -20,7 +20,7 @@ import { createDiagnosisPlan, runDiagnosis } from "../diagnosis.ts"; const report = cliDiagnosisReportMetadata(process.env.TRUAPI_CLI_HOST_ROLE); const DEFAULT_REPORT_PATH = fileURLToPath( new URL( - `../../../../../explorer/diagnosis-reports/${report.filename}`, + `../../../../../explorer/diagnosis-reports/spa/${report.filename}`, import.meta.url, ), ); diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 66d615a3..6d670803 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -1324,18 +1324,28 @@ async fn register_pairing_allowances( .await .map_err(anyhow::Error::msg)?; - // The signing account may be in an old ring, so scan back to genesis. - let current = alloc::ring::read_current_ring_index(&rpc) - .await - .map_err(anyhow::Error::msg)?; - let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) - .await - .map_err(anyhow::Error::msg)? - .ok_or_else(|| { - anyhow::anyhow!( - "signing account is not a LitePeople ring member; cannot grant allowance" - ) - })?; + // Account provisioning waits for ring membership on a separate RPC + // connection. A load-balanced endpoint can briefly route this fresh + // connection to a node that has not observed the same ring yet. + let mut ring = None; + for attempt in 1..=10 { + // The signing account may be in an old ring, so scan back to genesis. + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)?; + if ring.is_some() { + break; + } + if attempt < 10 { + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + } + } + let ring = ring.ok_or_else(|| { + anyhow::anyhow!("signing account is not a LitePeople ring member; cannot grant allowance") + })?; terminal_ui::output_event(SystemEvent::RingInfo { ring_index: ring.ring_index, members: ring.members.len(), diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 7b1a5e18..60a86868 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -953,7 +953,8 @@ pub trait PreimageHost: Send + Sync { ) -> BoxStream<'static, Result>, GenericError>>; } -/// Optional native chat storage and UI adapter used by Chat executions. +/// Host-implemented adapter through which product Chat calls reach native +/// storage and UI. #[async_trait] pub trait ChatPlatform: Send + Sync { /// Create or resolve a product-scoped native chat room. diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index e54da4bc..eeafa089 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -926,7 +926,7 @@ where let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Chat); let host = host; dispatcher.on_stream_pair( - wire_table::CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE, + wire_table::CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL, move |request_id: String, bytes: Vec, requests| { let host = host.clone(); Box::pin(async move { @@ -936,11 +936,11 @@ where return Err(Vec::new()); } let requests = subscription_request_stream::< - versioned::chat::ProductChatCustomMessageRenderSubscribeRequest, + versioned::chat::ProductChatCustomMessageRenderChannelRequest, >(requests); - let stream = host.custom_message_render_subscribe(&cx, requests).await; + let stream = host.custom_message_render_channel(&cx, requests).await; Ok(subscription_stream::< - versioned::chat::ProductChatCustomMessageRenderSubscribeItem, + versioned::chat::ProductChatCustomMessageRenderChannelItem, _, >(stream)) }) diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 7360d042..d5f78d3d 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -190,8 +190,8 @@ pub const CHAT_ACTION_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { receive_id: 51, }; -/// Wire discriminants for `chat_custom_message_render_subscribe`. -pub const CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { +/// Wire discriminants for `chat_custom_message_render_channel`. +pub const CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL: SubscriptionFrameIds = SubscriptionFrameIds { start_id: 52, stop_id: 53, interrupt_id: 54, @@ -562,8 +562,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), }, WireEntry { - method: "chat_custom_message_render_subscribe", - kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE), + method: "chat_custom_message_render_channel", + kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL), }, WireEntry { method: "statement_store_subscribe", diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index b8958f85..97bb3d87 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -568,7 +568,8 @@ pub struct ProductRuntime { next_dispatch_id: AtomicU64, } -/// Cloneable native control surface for one concrete product connection. +/// Host-facing control handle for pushing native events into one concrete +/// product connection. #[derive(Clone)] pub struct ProductRuntimeControl { runtime: Arc, @@ -1002,7 +1003,7 @@ mod tests { test_spawner(), sink.clone(), ); - let ids = subscription_ids("chat_custom_message_render_subscribe") + let ids = subscription_ids("chat_custom_message_render_channel") .expect("known renderer stream pair"); let frame = ProtocolMessage { request_id: "chat:renderer".into(), diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index ab617824..8e4b3c54 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -1073,11 +1073,11 @@ impl NativeProductExecution { /// Permanently close this executable and all of its connection state. pub fn close(&self) { - if self.closed.swap(true, Ordering::AcqRel) { - return; - } #[cfg(feature = "ws-bridge")] { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } if let Some(mut bridge) = self .bridge .lock() @@ -1092,6 +1092,8 @@ impl NativeProductExecution { .expect("native product control mutex poisoned") = NativeProductControlState::default(); } + #[cfg(not(feature = "ws-bridge"))] + self.closed.swap(true, Ordering::AcqRel); } } @@ -1316,17 +1318,17 @@ impl NativeTrUApiCore { /// Publish one native Chat action to the connected product worker. pub fn publish_chat_action(&self, action: NativeChatAction) -> Result<(), NativeChatError> { - #[cfg(feature = "ws-bridge")] + if self.product.execution_kind != ProductExecutionKind::Chat + || !self.runtime.has_active_session() { - if self.product.execution_kind != ProductExecutionKind::Chat - || !self.runtime.has_active_session() - { - return Err(NativeChatError::Denied); - } - if !self.runtime.supports_chat() { - return Err(NativeChatError::Unsupported); - } + return Err(NativeChatError::Denied); + } + if !self.runtime.supports_chat() { + return Err(NativeChatError::Unsupported); + } + #[cfg(feature = "ws-bridge")] + { let action: v01::HostChatActionSubscribeItem = action.into(); let mut state = self .product_control @@ -1361,16 +1363,17 @@ impl NativeTrUApiCore { payload: Vec, observer: Box, ) -> Result, NativeChatError> { + if self.product.execution_kind != ProductExecutionKind::Chat + || !self.runtime.has_active_session() + { + return Err(NativeChatError::Denied); + } + if !self.runtime.supports_chat() { + return Err(NativeChatError::Unsupported); + } + #[cfg(feature = "ws-bridge")] { - if self.product.execution_kind != ProductExecutionKind::Chat - || !self.runtime.has_active_session() - { - return Err(NativeChatError::Denied); - } - if !self.runtime.supports_chat() { - return Err(NativeChatError::Unsupported); - } let control = self .product_control .lock() @@ -1965,19 +1968,25 @@ impl truapi_platform::ChatPlatform for CallbackPlatform { _product: &ProductContext, request: v01::HostChatCreateRoomRequest, ) -> Result { - self.callbacks + let status = self + .callbacks .chat_create_room(request.room_id, request.name, request.icon) - .map(|status| v01::HostChatCreateRoomResponse { - status: match status { - NativeChatRoomRegistrationStatus::New => v01::ChatRoomRegistrationStatus::New, - NativeChatRoomRegistrationStatus::Exists => { - v01::ChatRoomRegistrationStatus::Exists - } - }, - }) .map_err(|error| v01::HostChatCreateRoomError::Unknown { reason: error.to_string(), - }) + })?; + + if status == NativeChatRoomRegistrationStatus::New + && let Ok(rooms) = self.callbacks.chat_list_rooms() + { + self.events.notify_chat_rooms_changed(rooms); + } + + Ok(v01::HostChatCreateRoomResponse { + status: match status { + NativeChatRoomRegistrationStatus::New => v01::ChatRoomRegistrationStatus::New, + NativeChatRoomRegistrationStatus::Exists => v01::ChatRoomRegistrationStatus::Exists, + }, + }) } async fn post_message( @@ -2192,6 +2201,23 @@ mod tests { .push((room_id, text)); Ok("message-id".to_string()) } + + fn chat_list_rooms(&self) -> Result, HostRejection> { + let mut room_ids = self + .chat_created_rooms + .lock() + .expect("created rooms mutex poisoned") + .clone(); + room_ids.sort(); + room_ids.dedup(); + Ok(room_ids + .into_iter() + .map(|room_id| NativeChatRoom { + room_id, + is_host: true, + }) + .collect()) + } } fn event_platform() -> (Arc, Arc, CallbackPlatform) { @@ -2274,6 +2300,7 @@ mod tests { }), Err(NativeChatError::Denied) )); + #[cfg(feature = "ws-bridge")] chat.publish_chat_action(NativeChatAction::MessagePostedText { room_id: "room".to_string(), peer: "native".to_string(), @@ -2295,6 +2322,8 @@ mod tests { }), Err(NativeChatError::Closed) )); + assert!(!replacement.closed.load(Ordering::Acquire)); + #[cfg(feature = "ws-bridge")] replacement .publish_chat_action(NativeChatAction::MessagePostedText { room_id: "room".to_string(), @@ -2433,6 +2462,13 @@ mod tests { name: "Support".to_string(), icon: String::new(), }; + let mut rooms = truapi_platform::ChatPlatform::subscribe_rooms(&platform, &product); + assert!( + futures::executor::block_on(rooms.next()) + .expect("initial room list") + .rooms + .is_empty() + ); let created = futures::executor::block_on(truapi_platform::ChatPlatform::create_room( &platform, @@ -2440,6 +2476,8 @@ mod tests { request.clone(), )) .unwrap(); + let updated_rooms = + futures::executor::block_on(rooms.next()).expect("created room replacement"); *callbacks .chat_room_status .lock() @@ -2461,6 +2499,8 @@ mod tests { .unwrap(); assert_eq!(created.status, v01::ChatRoomRegistrationStatus::New); + assert_eq!(updated_rooms.rooms.len(), 1); + assert_eq!(updated_rooms.rooms[0].room_id, "support"); assert_eq!(existing.status, v01::ChatRoomRegistrationStatus::Exists); assert_eq!(posted.message_id, "message-id"); assert_eq!( diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 87bc1102..01cfa2ad 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -109,7 +109,7 @@ use truapi::versioned::chat::{ HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, - ProductChatCustomMessageRenderSubscribeItem, ProductChatCustomMessageRenderSubscribeRequest, + ProductChatCustomMessageRenderChannelItem, ProductChatCustomMessageRenderChannelRequest, }; use truapi::versioned::entropy::{ HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, @@ -1869,12 +1869,12 @@ impl Chat for ProductRuntimeHost { self.chat.subscribe_actions() } - #[instrument(skip_all, fields(runtime.method = "chat.custom_message_render_subscribe"))] - async fn custom_message_render_subscribe( + #[instrument(skip_all, fields(runtime.method = "chat.custom_message_render_channel"))] + async fn custom_message_render_channel( &self, _cx: &CallContext, - requests: Subscription, - ) -> Subscription { + requests: Subscription, + ) -> Subscription { if !self.chat_streams_available() { return Subscription::empty(); } diff --git a/rust/crates/truapi-server/src/runtime/chat.rs b/rust/crates/truapi-server/src/runtime/chat.rs index f2a210ba..ac0923a4 100644 --- a/rust/crates/truapi-server/src/runtime/chat.rs +++ b/rust/crates/truapi-server/src/runtime/chat.rs @@ -6,8 +6,8 @@ use std::sync::{Arc, Mutex}; use futures::StreamExt; use futures::channel::mpsc; use truapi::versioned::chat::{ - HostChatActionSubscribeItem, ProductChatCustomMessageRenderSubscribeItem, - ProductChatCustomMessageRenderSubscribeRequest, + HostChatActionSubscribeItem, ProductChatCustomMessageRenderChannelItem, + ProductChatCustomMessageRenderChannelRequest, }; use truapi::{Subscription, v01}; @@ -18,7 +18,7 @@ const ACTION_BUFFER_CAPACITY: usize = 64; struct RendererState { generation: u64, - work: mpsc::UnboundedSender, + work: mpsc::UnboundedSender, renders: HashMap>, } @@ -96,8 +96,8 @@ impl ChatConnection { /// Register the product's paired renderer streams for this connection. pub(crate) fn register_renderer( &self, - mut requests: Subscription, - ) -> Subscription { + mut requests: Subscription, + ) -> Subscription { let (work, receiver) = mpsc::unbounded(); let generation = { let mut state = self.state.lock().expect("chat state mutex poisoned"); @@ -126,8 +126,8 @@ impl ChatConnection { break; }; match request { - ProductChatCustomMessageRenderSubscribeRequest::V1( - v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::Update { message_id, node, }, @@ -138,8 +138,8 @@ impl ChatConnection { renderer.renders.remove(&message_id); } } - ProductChatCustomMessageRenderSubscribeRequest::V1( - v01::ProductChatCustomMessageRenderSubscribeRequest::Failed { message_id }, + ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::Failed { message_id }, ) => { renderer.renders.remove(&message_id); } @@ -175,8 +175,8 @@ impl ChatConnection { .as_mut() .ok_or(ProductRuntimeError::Unsupported)?; renderer.renders.insert(message_id.clone(), sender); - let item = ProductChatCustomMessageRenderSubscribeItem::V1( - v01::ProductChatCustomMessageRenderSubscribeItem { + let item = ProductChatCustomMessageRenderChannelItem::V1( + v01::ProductChatCustomMessageRenderChannelItem { message_id: message_id.clone(), message_type, payload, @@ -273,8 +273,8 @@ mod tests { assert_eq!( block_on(work.next()), - Some(ProductChatCustomMessageRenderSubscribeItem::V1( - v01::ProductChatCustomMessageRenderSubscribeItem { + Some(ProductChatCustomMessageRenderChannelItem::V1( + v01::ProductChatCustomMessageRenderChannelItem { message_id: "one".into(), message_type: "vote".into(), payload: vec![1], @@ -283,8 +283,8 @@ mod tests { ); assert_eq!( block_on(work.next()), - Some(ProductChatCustomMessageRenderSubscribeItem::V1( - v01::ProductChatCustomMessageRenderSubscribeItem { + Some(ProductChatCustomMessageRenderChannelItem::V1( + v01::ProductChatCustomMessageRenderChannelItem { message_id: "two".into(), message_type: "balance".into(), payload: vec![2], @@ -296,8 +296,8 @@ mod tests { text: "second".into(), }; requests_tx - .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( - v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + .unbounded_send(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::Update { message_id: "two".into(), node: node.clone(), }, @@ -307,8 +307,8 @@ mod tests { assert_eq!(block_on(second.next()), Some(node)); requests_tx - .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( - v01::ProductChatCustomMessageRenderSubscribeRequest::Failed { + .unbounded_send(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::Failed { message_id: "one".into(), }, )) @@ -328,8 +328,8 @@ mod tests { for text in ["first", "second"] { requests_tx - .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( - v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + .unbounded_send(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::Update { message_id: "one".into(), node: CustomRendererNode::String { text: text.into() }, }, @@ -409,8 +409,8 @@ mod tests { text: "first product".into(), }; first_requests_tx - .unbounded_send(ProductChatCustomMessageRenderSubscribeRequest::V1( - v01::ProductChatCustomMessageRenderSubscribeRequest::Update { + .unbounded_send(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::Update { message_id: "same-id".into(), node: node.clone(), }, diff --git a/rust/crates/truapi/src/api/chat.rs b/rust/crates/truapi/src/api/chat.rs index e7e2722b..ac131a91 100644 --- a/rust/crates/truapi/src/api/chat.rs +++ b/rust/crates/truapi/src/api/chat.rs @@ -5,7 +5,7 @@ use crate::versioned::chat::{ HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, - ProductChatCustomMessageRenderSubscribeItem, ProductChatCustomMessageRenderSubscribeRequest, + ProductChatCustomMessageRenderChannelItem, ProductChatCustomMessageRenderChannelRequest, }; use crate::wire; use crate::{CallContext, CallError, Subscription}; @@ -106,16 +106,20 @@ pub trait Chat: Send + Sync { Subscription::empty() } - /// Open the paired custom-message renderer streams. + /// Serves custom-message rendering over a product-initiated channel. /// - /// The product subscribes to the returned stream for render work and - /// publishes replacement trees through `requests`. + /// The product passes its request stream and subscribes to the returned + /// stream for render work. The channel is single-use: the one + /// subscription is the operation, and a new operation is a new call. /// /// ```ts - /// const renderer = truapi.chat.customMessageRenderSubscribe(); - /// renderer.responses.subscribe({ + /// import { Subject } from "rxjs"; + /// import type { ProductChatCustomMessageRenderChannelRequest } from "@parity/truapi"; + /// + /// const requests = new Subject(); + /// truapi.chat.customMessageRenderChannel(requests).subscribe({ /// next(item) { - /// renderer.requests.next({ + /// requests.next({ /// tag: "Failed", /// value: { messageId: item.messageId }, /// }); @@ -123,11 +127,11 @@ pub trait Chat: Send + Sync { /// }); /// ``` #[wire(start_id = 52)] - async fn custom_message_render_subscribe( + async fn custom_message_render_channel( &self, _cx: &CallContext, - _requests: Subscription, - ) -> Subscription { + _requests: Subscription, + ) -> Subscription { Subscription::empty() } } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index c560a875..1f9db162 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -64,11 +64,11 @@ pub mod latest { /// Native chat message posting failure. pub type HostChatPostMessageError = LatestOf; /// Product-to-host custom renderer update. - pub type ProductChatCustomMessageRenderSubscribeRequest = - LatestOf; + pub type ProductChatCustomMessageRenderChannelRequest = + LatestOf; /// Host-to-product custom render work item. - pub type ProductChatCustomMessageRenderSubscribeItem = - LatestOf; + pub type ProductChatCustomMessageRenderChannelItem = + LatestOf; /// Contextual alias derivation result. pub type HostAccountGetAliasResponse = LatestOf; diff --git a/rust/crates/truapi/src/v01/chat/custom_renderer.rs b/rust/crates/truapi/src/v01/chat/custom_renderer.rs index ccbfde47..7901d445 100644 --- a/rust/crates/truapi/src/v01/chat/custom_renderer.rs +++ b/rust/crates/truapi/src/v01/chat/custom_renderer.rs @@ -311,7 +311,7 @@ pub enum CustomRendererNode { /// Values sent by a product on its custom-message renderer request stream. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum ProductChatCustomMessageRenderSubscribeRequest { +pub enum ProductChatCustomMessageRenderChannelRequest { /// Replace the native tree for an active render instance. Update { /// Identifier supplied by the host in the corresponding render item. @@ -326,10 +326,10 @@ pub enum ProductChatCustomMessageRenderSubscribeRequest { }, } -/// Custom-message render work sent by the host to the product. +/// Render item sent from Rust to the product on the renderer response stream. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ProductChatCustomMessageRenderSubscribeItem { - /// Stable identifier used to correlate updates and widget actions. +pub struct ProductChatCustomMessageRenderChannelItem { + /// Stable identifier used to correlate updates and triggered actions. pub message_id: String, /// Product-defined discriminator used to select a renderer. pub message_type: String, diff --git a/rust/crates/truapi/src/versioned/chat.rs b/rust/crates/truapi/src/versioned/chat.rs index bfa2f85e..f568eaf5 100644 --- a/rust/crates/truapi/src/versioned/chat.rs +++ b/rust/crates/truapi/src/versioned/chat.rs @@ -14,6 +14,6 @@ truapi_macros::versioned_type! { pub enum HostChatPostMessageError { V1 => v01::HostChatPostMessageError } pub enum HostChatListSubscribeItem { V1 => v01::HostChatListSubscribeItem } pub enum HostChatActionSubscribeItem { V1 => v01::HostChatActionSubscribeItem } - pub enum ProductChatCustomMessageRenderSubscribeRequest { V1 => v01::ProductChatCustomMessageRenderSubscribeRequest } - pub enum ProductChatCustomMessageRenderSubscribeItem { V1 => v01::ProductChatCustomMessageRenderSubscribeItem } + pub enum ProductChatCustomMessageRenderChannelRequest { V1 => v01::ProductChatCustomMessageRenderChannelRequest } + pub enum ProductChatCustomMessageRenderChannelItem { V1 => v01::ProductChatCustomMessageRenderChannelItem } } diff --git a/scripts/battery.sh b/scripts/battery.sh index 63ffea77..c0273dfd 100755 --- a/scripts/battery.sh +++ b/scripts/battery.sh @@ -2,9 +2,9 @@ # Run the generated full-surface battery against the headless truapi-host CLI, # built from source, and write both committed CLI diagnosis reports: # -# explorer/diagnosis-reports/signing-host-cli.md direct signing-host run -# explorer/diagnosis-reports/pairing-host-cli.md pairing host, paired with a -# signing host this script starts +# explorer/diagnosis-reports/spa/signing-host-cli.md direct signing-host run +# explorer/diagnosis-reports/spa/pairing-host-cli.md pairing host, paired with a +# signing host this script starts # # Usage: # scripts/battery.sh # both phases @@ -41,7 +41,7 @@ unset DYLD_LIBRARY_PATH SCRIPT="rust/crates/truapi-host-cli/js/scripts/battery.ts" PRODUCT_ID="truapi-playground.dot" -REPORTS="explorer/diagnosis-reports" +REPORTS="explorer/diagnosis-reports/spa" LOG_DIR="target/battery" PAIRING_STATE="target/battery/pairing-host-state" PHASE_TIMEOUT="${BATTERY_PHASE_TIMEOUT:-900}" diff --git a/scripts/launch-ios-chat-playground.mjs b/scripts/launch-ios-chat-playground.mjs index c5f8881b..0249e27d 100755 --- a/scripts/launch-ios-chat-playground.mjs +++ b/scripts/launch-ios-chat-playground.mjs @@ -10,9 +10,14 @@ import { readFileSync, statSync, unlinkSync, + writeFileSync, } from "node:fs"; import { createServer } from "node:http"; import { dirname, extname, resolve, sep } from "node:path"; +import { + decodeTextMessage, + labelChatDiagnosisReport, +} from "./lib/chat-diagnosis-report.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const bundle = @@ -32,12 +37,15 @@ const productHost = const productName = process.env.TRUAPI_IOS_E2E_CHAT_PRODUCT_NAME ?? "TrUAPI Playground"; const roomId = process.env.TRUAPI_IOS_E2E_CHAT_ROOM_ID ?? "truapi-playground"; -const message = process.env.TRUAPI_IOS_E2E_CHAT_MESSAGE ?? "!echo hello"; +const expectDiagnosis = process.env.TRUAPI_IOS_E2E_CHAT_DIAGNOSIS !== "0"; +const message = + process.env.TRUAPI_IOS_E2E_CHAT_MESSAGE ?? + (expectDiagnosis ? "!diagnose" : "!echo hello"); const expectedReply = process.env.TRUAPI_IOS_E2E_CHAT_EXPECTED_REPLY ?? "Echo: hello"; const expectedStartupMessage = - process.env.TRUAPI_IOS_E2E_CHAT_EXPECTED_STARTUP_MESSAGE ?? - 'Chat API checks passed. Send "!echo " to test actions.'; + process.env.TRUAPI_IOS_E2E_CHAT_EXPECTED_STARTUP_MESSAGE ?? ""; +const reportHeading = "## Truapi Chat Diagnosis"; const expectCustomRenderer = process.env.TRUAPI_IOS_E2E_CHAT_EXPECT_CUSTOM_RENDERER !== "0"; const worker = resolve(productRoot, "out/worker/index.js"); @@ -48,6 +56,11 @@ const screenshot = resolve( process.env.TRUAPI_IOS_E2E_CHAT_SCREENSHOT ?? "artifacts/truapi-playground-chat.png", ); +const reportPath = resolve( + repoRoot, + process.env.TRUAPI_IOS_E2E_CHAT_REPORT ?? + "playground/test-results/ios-chat/diagnosis-report.md", +); if (!existsSync(app)) { throw new Error(`iOS app bundle not found: ${app}`); @@ -89,7 +102,6 @@ const appData = capture("xcrun", [ "data", ]).trim(); const connectionMarkers = [ - resolve(appData, "tmp/truapi-e2e", `connected-app-${productHost}`), resolve(appData, "tmp/truapi-e2e", `connected-chat-${productHost}`), ]; const customRendererMarker = resolve( @@ -144,21 +156,37 @@ try { }, ); - await waitForFiles(connectionMarkers, 30_000); - if (expectedStartupMessage) { - await waitForReply( + await waitForFiles( + connectionMarkers, + 60_000, + "Ensure the selected simulator has completed Polkadot onboarding.", + ); + if (expectDiagnosis) { + const report = await waitForTextPrefix( userDataDatabase, chatIdentifier, messageWatermark, - expectedStartupMessage, + reportHeading, + ); + const hostReport = labelChatDiagnosisReport(report, "iOS", 5); + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync(reportPath, `${hostReport}\n`); + } else { + if (expectedStartupMessage) { + await waitForTextPrefix( + userDataDatabase, + chatIdentifier, + messageWatermark, + expectedStartupMessage, + ); + } + await waitForTextPrefix( + userDataDatabase, + chatIdentifier, + messageWatermark, + expectedReply, ); } - await waitForReply( - userDataDatabase, - chatIdentifier, - messageWatermark, - expectedReply, - ); if (expectCustomRenderer) { await waitForFiles([customRendererMarker], 30_000); } @@ -179,13 +207,13 @@ console.log( productName, roomId, message, - expectedReply, - expectedStartupMessage, + diagnosisVerified: expectDiagnosis, customRendererVerified: expectCustomRenderer, productUrl, - connectedExecutions: ["App", "Chat"], + verifiedExecutions: ["Chat"], worker, workerDestination, + report: expectDiagnosis ? reportPath : undefined, screenshot, verified: true, }), @@ -229,11 +257,7 @@ async function startProductServer(urlString, root) { } response.setHeader("Content-Type", contentType(file)); const content = readFileSync(file); - response.end( - extname(file) === ".html" - ? injectAppConnectionProbe(content.toString("utf8")) - : content, - ); + response.end(content); } catch { response.writeHead(404).end(); } @@ -245,26 +269,6 @@ async function startProductServer(urlString, root) { return server; } -function injectAppConnectionProbe(html) { - const probe = ``; - return html.replace("", `${probe}`); -} - function contentType(file) { switch (extname(file)) { case ".html": @@ -284,7 +288,7 @@ function contentType(file) { } } -async function waitForFiles(files, timeoutMs) { +async function waitForFiles(files, timeoutMs, hint) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (files.every(existsSync)) { @@ -293,7 +297,7 @@ async function waitForFiles(files, timeoutMs) { await delay(250); } throw new Error( - `Timed out waiting for execution connections: ${files.join(", ")}`, + `Timed out waiting for files: ${files.join(", ")}${hint ? `\n${hint}` : ""}`, ); } @@ -308,36 +312,36 @@ function latestMessageId(database, identifier) { return Number.parseInt(value, 10) || 0; } -async function waitForReply(database, identifier, afterMessageId, reply) { - const expectedPayload = Buffer.concat([ - Buffer.of(0), - encodeScaleCompact(Buffer.byteLength(reply, "utf8")), - Buffer.from(reply, "utf8"), - ]) - .toString("hex") - .toUpperCase(); +async function waitForTextPrefix(database, identifier, afterMessageId, prefix) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { if (existsSync(database)) { const query = ` - SELECT COUNT(*) + SELECT hex(content.ZDATA) FROM ZCDCHATMESSAGE AS message JOIN ZCDCHAT AS chat ON chat.Z_PK = message.ZCHAT JOIN ZCDMESSAGECONTENT AS content ON content.Z_PK = message.ZCONTENT WHERE chat.ZIDENTIFIER = ${sqlString(identifier)} AND message.Z_PK > ${afterMessageId} - AND hex(content.ZDATA) = ${sqlString(expectedPayload)}; + ORDER BY message.Z_PK; `; - if (capture("sqlite3", [database, query]).trim() !== "0") { - return; + const values = capture("sqlite3", [database, query]) + .trim() + .split(/\r?\n/) + .filter(Boolean); + for (const value of values) { + const text = decodeTextMessage(value); + if (text?.startsWith(prefix)) { + return text; + } } } await delay(250); } throw new Error( - `Timed out waiting for ${JSON.stringify(reply)} in ${identifier}`, + `Timed out waiting for a message starting with ${JSON.stringify(prefix)} in ${identifier}`, ); } @@ -345,25 +349,6 @@ function delay(milliseconds) { return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); } -function encodeScaleCompact(value) { - if (value < 1 << 6) { - return Buffer.of(value << 2); - } - if (value < 1 << 14) { - const encoded = (value << 2) | 1; - const result = Buffer.allocUnsafe(2); - result.writeUInt16LE(encoded); - return result; - } - if (value < 1 << 30) { - const encoded = value * 4 + 2; - const result = Buffer.allocUnsafe(4); - result.writeUInt32LE(encoded); - return result; - } - throw new Error("Expected reply is too large for this E2E assertion"); -} - function sqlString(value) { return `'${value.replaceAll("'", "''")}'`; } diff --git a/scripts/lib/chat-diagnosis-report.mjs b/scripts/lib/chat-diagnosis-report.mjs new file mode 100644 index 00000000..a5328d3d --- /dev/null +++ b/scripts/lib/chat-diagnosis-report.mjs @@ -0,0 +1,36 @@ +/** Decode a SCALE-encoded iOS Chat text message stored in CoreData. */ +export function decodeTextMessage(hex) { + const encoded = Buffer.from(hex, "hex"); + if (encoded[0] !== 0) return undefined; + const compact = decodeScaleCompact(encoded, 1); + const start = 1 + compact.bytes; + return encoded.subarray(start, start + compact.value).toString("utf8"); +} + +/** Validate a successful Chat report and attach the native host label. */ +export function labelChatDiagnosisReport(report, host, expectedSuccesses) { + const heading = "## Truapi Chat Diagnosis"; + if ( + !report.startsWith(heading) || + !report.includes(`**${expectedSuccesses} success · 0 failed**`) || + report.includes("❌") + ) { + throw new Error(`Chat diagnosis reported a failure:\n${report}`); + } + return report.replace(heading, `## Truapi ${host} Chat Diagnosis`); +} + +function decodeScaleCompact(encoded, offset) { + const first = encoded[offset]; + const mode = first & 0b11; + if (mode === 0) return { value: first >> 2, bytes: 1 }; + if (mode === 1) { + return { value: encoded.readUInt16LE(offset) >> 2, bytes: 2 }; + } + if (mode === 2) { + return { value: encoded.readUInt32LE(offset) >>> 2, bytes: 4 }; + } + throw new Error( + "Large SCALE compact values are not expected in Chat reports", + ); +} diff --git a/scripts/lib/chat-diagnosis-report.test.mjs b/scripts/lib/chat-diagnosis-report.test.mjs new file mode 100644 index 00000000..9795a100 --- /dev/null +++ b/scripts/lib/chat-diagnosis-report.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + decodeTextMessage, + labelChatDiagnosisReport, +} from "./chat-diagnosis-report.mjs"; + +test("decodes the multi-byte compact length used by a Chat report", () => { + const text = `## Truapi Chat Diagnosis\n${"result ".repeat(20)}`; + const body = Buffer.from(text); + const compact = Buffer.alloc(2); + compact.writeUInt16LE((body.length << 2) | 1); + const encoded = Buffer.concat([Buffer.of(0), compact, body]); + + assert.equal(decodeTextMessage(encoded.toString("hex")), text); + assert.equal(decodeTextMessage(Buffer.of(252).toString("hex")), undefined); +}); + +test("labels only a successful Chat-only report", () => { + const report = [ + "## Truapi Chat Diagnosis", + "", + "**5 success · 0 failed**", + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + "| `Chat/create_room` | ✅ | created |", + ].join("\n"); + + assert.match( + labelChatDiagnosisReport(report, "iOS", 5), + /^## Truapi iOS Chat Diagnosis/, + ); + assert.throws(() => + labelChatDiagnosisReport(report.replace("0 failed", "1 failed"), "iOS", 5), + ); +}); From 5d319fce3fd8eaa19eb29266f44ab0e09535229f Mon Sep 17 00:00:00 2001 From: pg Date: Mon, 3 Aug 2026 08:11:55 +0200 Subject: [PATCH 4/5] refactor: simplify shared-core chat integration --- docs/design/chat-modality-product-sdk.md | 3 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 76 ++- playground/worker/diagnosis.ts | 16 +- playground/worker/index.ts | 106 ++-- rust/crates/truapi-codegen/src/ts.rs | 89 ++- rust/crates/truapi-server/src/dispatcher.rs | 65 +-- rust/crates/truapi-server/src/host_core.rs | 136 ++--- rust/crates/truapi-server/src/native.rs | 514 +++++++----------- .../truapi-server/src/native_renderer.rs | 53 +- rust/crates/truapi-server/src/runtime.rs | 37 +- rust/crates/truapi-server/src/runtime/chat.rs | 35 +- .../truapi-server/src/runtime/services.rs | 20 +- rust/crates/truapi-server/src/subscription.rs | 91 ++-- scripts/launch-ios-chat-playground.mjs | 72 +-- .../launch-ios-playground-with-signer-bot.mjs | 8 +- scripts/launch-ios-playground.mjs | 76 +-- scripts/lib/ios-simulator.mjs | 82 +++ 17 files changed, 603 insertions(+), 876 deletions(-) create mode 100644 scripts/lib/ios-simulator.mjs diff --git a/docs/design/chat-modality-product-sdk.md b/docs/design/chat-modality-product-sdk.md index 7d098fd0..b8e0e004 100644 --- a/docs/design/chat-modality-product-sdk.md +++ b/docs/design/chat-modality-product-sdk.md @@ -42,7 +42,7 @@ setup: worker calls chat.start(...) -> install local action and renderer callbacks -> when a renderer is supplied: - open custom_message_render_subscribe + open custom_message_render_channel -> optionally open chat_list_subscribe -> open chat_action_subscribe last -> resolve @@ -136,4 +136,3 @@ The Product SDK integration is complete when: ## References - [Shared-core Chat protocol](./chat-modality-shared-core.md) -- [Shared-core implementation plan](./chat-modality-shared-core-implementation-plan.md) diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 680d9392..bbac30e9 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -758,20 +758,14 @@ public final class TrUAPIProductExecution: @unchecked Sendable { messageType: String, payload: Data ) throws -> AsyncThrowingStream { - let (stream, continuation) = AsyncThrowingStream.makeStream( - of: NativeCustomRendererNode.self - ) - let observer = CustomRendererStreamObserver(continuation: continuation) - let subscription = try inner.renderCustomMessage( - messageId: messageId, - messageType: messageType, - payload: payload, - observer: observer - ) - continuation.onTermination = { @Sendable _ in - subscription.cancel() + try customRendererStream { observer in + try inner.renderCustomMessage( + messageId: messageId, + messageType: messageType, + payload: payload, + observer: observer + ) } - return stream } public func permissionAuthorizationStatus( @@ -853,20 +847,14 @@ public final class TrUAPIHostCore { messageType: String, payload: Data ) throws -> AsyncThrowingStream { - let (stream, continuation) = AsyncThrowingStream.makeStream( - of: NativeCustomRendererNode.self - ) - let observer = CustomRendererStreamObserver(continuation: continuation) - let subscription = try inner.renderCustomMessage( - messageId: messageId, - messageType: messageType, - payload: payload, - observer: observer - ) - continuation.onTermination = { @Sendable _ in - subscription.cancel() + try customRendererStream { observer in + try inner.renderCustomMessage( + messageId: messageId, + messageType: messageType, + payload: payload, + observer: observer + ) } - return stream } /// Core-owned logout/disconnect path. Best-effort notifies the SSO peer, @@ -938,6 +926,20 @@ public final class TrUAPIHostCore { } } +private func customRendererStream( + _ subscribe: (CustomRendererStreamObserver) throws -> NativeCustomRendererSubscription +) throws -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream( + of: NativeCustomRendererNode.self + ) + let observer = CustomRendererStreamObserver(continuation: continuation) + let subscription = try subscribe(observer) + continuation.onTermination = { @Sendable _ in + subscription.cancel() + } + return stream +} + private final class CustomRendererStreamObserver: NativeCustomRendererObserver, @unchecked Sendable { private let continuation: AsyncThrowingStream.Continuation @@ -954,30 +956,22 @@ private final class CustomRendererStreamObserver: NativeCustomRendererObserver, } } -private final class WeakTrUAPIHostCore { - weak var value: TrUAPIHostCore? - - init(_ value: TrUAPIHostCore) { - self.value = value - } -} - -private final class WeakTrUAPIHostRuntime { - weak var value: TrUAPIHostRuntime? +private final class WeakReference { + weak var value: Value? - init(_ value: TrUAPIHostRuntime) { + init(_ value: Value) { self.value = value } } private enum LiveSessionStoreForwarder { private static let lock = NSLock() - private static var cores: [ObjectIdentifier: WeakTrUAPIHostCore] = [:] - private static var runtimes: [ObjectIdentifier: WeakTrUAPIHostRuntime] = [:] + private static var cores: [ObjectIdentifier: WeakReference] = [:] + private static var runtimes: [ObjectIdentifier: WeakReference] = [:] static func register(_ runtime: TrUAPIHostRuntime) { lock.lock() - runtimes[ObjectIdentifier(runtime)] = WeakTrUAPIHostRuntime(runtime) + runtimes[ObjectIdentifier(runtime)] = WeakReference(runtime) lock.unlock() } @@ -989,7 +983,7 @@ private enum LiveSessionStoreForwarder { static func register(_ core: TrUAPIHostCore) { lock.lock() - cores[ObjectIdentifier(core)] = WeakTrUAPIHostCore(core) + cores[ObjectIdentifier(core)] = WeakReference(core) lock.unlock() } diff --git a/playground/worker/diagnosis.ts b/playground/worker/diagnosis.ts index 21bc8553..07627f9a 100644 --- a/playground/worker/diagnosis.ts +++ b/playground/worker/diagnosis.ts @@ -14,6 +14,9 @@ export const CHAT_DIAGNOSIS_METHODS = [ export type ChatDiagnosisMethod = (typeof CHAT_DIAGNOSIS_METHODS)[number]; +export const CHAT_DIAGNOSIS_REFRESH_ACTION = "truapi-chat-diagnosis-refresh"; +export const CHAT_DIAGNOSIS_COPY_ACTION = "truapi-chat-diagnosis-copy"; + type CopyStatus = "idle" | "copied" | "unavailable"; const STATUS_ICON = { @@ -43,13 +46,12 @@ export class ChatDiagnosis { } fail(id: ChatDiagnosisMethod, error: unknown): void { - const details = error instanceof Error ? error.message : String(error); - this.#results.set(id, { id, status: "fail", details }); + this.#results.set(id, { id, status: "fail", details: errorDetails(error) }); this.#onChange(); } failPending(error: unknown): void { - const details = error instanceof Error ? error.message : String(error); + const details = errorDetails(error); for (const id of CHAT_DIAGNOSIS_METHODS) { if (this.#results.get(id)?.status === "running") { this.#results.set(id, { id, status: "fail", details }); @@ -106,7 +108,7 @@ export class ChatDiagnosis { variant: "Secondary", enabled: true, loading: false, - clickAction: "truapi-chat-diagnosis-refresh", + clickAction: CHAT_DIAGNOSIS_REFRESH_ACTION, }, children: [], }, @@ -120,7 +122,7 @@ export class ChatDiagnosis { variant: "Secondary", enabled: true, loading: false, - clickAction: "truapi-chat-diagnosis-copy", + clickAction: CHAT_DIAGNOSIS_COPY_ACTION, }, children: [], }, @@ -137,6 +139,10 @@ export class ChatDiagnosis { } } +function errorDetails(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function column(children: CustomRendererNode[]): CustomRendererNode { return { tag: "Column", diff --git a/playground/worker/index.ts b/playground/worker/index.ts index 806594af..15d1b5c6 100644 --- a/playground/worker/index.ts +++ b/playground/worker/index.ts @@ -1,26 +1,30 @@ import { getClientSync } from "@parity/truapi/sandbox"; +import { bytesToHex, hexToBytes } from "@parity/truapi/scale"; import type { + CustomRendererNode, HostChatActionSubscribeItem, HostChatListSubscribeItem, - HexString, ObservableLike, ProductChatCustomMessageRenderChannelItem, ProductChatCustomMessageRenderChannelRequest, - Subscription, } from "@parity/truapi"; -import { Subject } from "rxjs"; -import { ChatDiagnosis } from "./diagnosis"; +import { filter, firstValueFrom, from, Subject, timeout } from "rxjs"; +import { + CHAT_DIAGNOSIS_COPY_ACTION, + CHAT_DIAGNOSIS_REFRESH_ACTION, + ChatDiagnosis, +} from "./diagnosis"; const ROOM_ID = "truapi-playground"; const ROOM_NAME = "TrUAPI Playground"; const DIAGNOSIS_COMMAND = "!diagnose"; const ECHO_COMMAND = "!echo"; const RENDER_MESSAGE_TYPE = "truapi-chat-diagnosis"; -const REFRESH_ACTION = "truapi-chat-diagnosis-refresh"; -const COPY_ACTION = "truapi-chat-diagnosis-copy"; const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const diagnosticRoomId = `${ROOM_ID}-diagnosis-${runId}`; -const renderPayload = encodeHex(JSON.stringify({ version: 1, runId })); +const renderPayload = bytesToHex( + new TextEncoder().encode(JSON.stringify({ version: 1, runId })), +); const client = getClientSync(); if (!client) { @@ -138,14 +142,13 @@ function handleRenderRequest( item: ProductChatCustomMessageRenderChannelItem, ): void { if (item.messageType !== RENDER_MESSAGE_TYPE) { - renderRequests.next({ - tag: "Failed", - value: { messageId: item.messageId }, - }); + rejectRender(item.messageId); return; } try { - const payload = JSON.parse(decodeHex(item.payload)) as { + const payload = JSON.parse( + new TextDecoder().decode(hexToBytes(item.payload)), + ) as { version?: number; runId?: string; }; @@ -155,10 +158,7 @@ function handleRenderRequest( // to renderer state that no longer exists, so reject them without turning // the current diagnosis red. if (payload.runId !== runId) { - renderRequests.next({ - tag: "Failed", - value: { messageId: item.messageId }, - }); + rejectRender(item.messageId); return; } if (payload.version !== 1) { @@ -175,42 +175,41 @@ function handleRenderRequest( } activeRenderMessageIds.add(item.messageId); - renderRequests.next({ - tag: "Update", - value: { messageId: item.messageId, node: diagnosis.rendererNode() }, - }); + updateRender(item.messageId, diagnosis.rendererNode()); diagnosis.pass( "Chat/custom_message_render_channel", "correlated render work and sent initial and replacement trees", ); } catch (error) { diagnosis.fail("Chat/custom_message_render_channel", error); - renderRequests.next({ - tag: "Failed", - value: { messageId: item.messageId }, - }); + rejectRender(item.messageId); } } function renderActiveMessages(): void { const node = diagnosis.rendererNode(); for (const messageId of activeRenderMessageIds) { - renderRequests.next({ - tag: "Update", - value: { messageId, node }, - }); + updateRender(messageId, node); } } +function updateRender(messageId: string, node: CustomRendererNode): void { + renderRequests.next({ tag: "Update", value: { messageId, node } }); +} + +function rejectRender(messageId: string): void { + renderRequests.next({ tag: "Failed", value: { messageId } }); +} + async function handleAction( action: HostChatActionSubscribeItem, ): Promise { if (action.payload.tag === "ActionTriggered") { const trigger = action.payload.value; if (trigger.messageId === customMessageId) { - if (trigger.actionId === REFRESH_ACTION) { + if (trigger.actionId === CHAT_DIAGNOSIS_REFRESH_ACTION) { renderActiveMessages(); - } else if (trigger.actionId === COPY_ACTION) { + } else if (trigger.actionId === CHAT_DIAGNOSIS_COPY_ACTION) { await copyDiagnosisReport(); } } @@ -298,43 +297,12 @@ async function waitForRoom( observable: ObservableLike, roomId: string, ): Promise { - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - subscription.unsubscribe(); - reject(new Error(`Timed out waiting for room ${roomId}`)); - }, 10_000); - const subscription: Subscription = observable.subscribe({ - next(item) { - if (item.rooms.some((candidate) => candidate.roomId === roomId)) { - queueMicrotask(() => { - clearTimeout(timeout); - subscription.unsubscribe(); - }); - resolve(); - } - }, - error(error) { - clearTimeout(timeout); - reject(error); - }, - complete() { - clearTimeout(timeout); - reject(new Error(`Chat room list ended before returning ${roomId}`)); - }, - }); - }); -} - -function encodeHex(value: string): HexString { - return `0x${Array.from(new TextEncoder().encode(value), (byte) => - byte.toString(16).padStart(2, "0"), - ).join("")}`; -} - -function decodeHex(value: HexString): string { - const bytes = value - .slice(2) - .match(/.{1,2}/g) - ?.map((byte) => Number.parseInt(byte, 16)); - return new TextDecoder().decode(Uint8Array.from(bytes ?? [])); + await firstValueFrom( + from(observable).pipe( + filter((item) => + item.rooms.some((candidate) => candidate.roomId === roomId), + ), + timeout({ first: 10_000 }), + ), + ); } diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 784f8e9f..4e9dbdd1 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -1069,22 +1069,32 @@ fn write_observable_helper(out: &mut String) { payload, decodeItem, decodeInterrupt, + onSubscribe, }}: {{ transport: TrUApiTransport; ids: SubscriptionFrameIds; payload: Uint8Array; decodeItem: (payload: Uint8Array) => Item; decodeInterrupt?: (payload: Uint8Array) => Reason; + onSubscribe?: (subscription: Subscription) => {{ unsubscribe(): void }}; }}): ObservableLike {{ const observable: ObservableLike = {{ subscribe(observer: Partial> = {{}}): Subscription {{ let closed = false; let raw: Subscription | undefined; + let forwarding: {{ unsubscribe(): void }} | undefined; + + const stopForwarding = () => {{ + const active = forwarding; + forwarding = undefined; + active?.unsubscribe(); + }}; const fail = (error: unknown, stop = true) => {{ if (closed) return; closed = true; try {{ + stopForwarding(); if (stop) raw?.unsubscribe(); }} finally {{ observer.error?.(toSubscriptionError(error)); @@ -1116,11 +1126,22 @@ fn write_observable_helper(out: &mut String) { return; }} closed = true; + stopForwarding(); observer.complete?.(); }}, onClose: fail, }}); + if (!closed && onSubscribe) {{ + try {{ + forwarding = onSubscribe(raw); + }} catch (error) {{ + raw.unsubscribe(); + throw error; + }} + if (closed) stopForwarding(); + }} + return {{ get subscriptionId() {{ return raw?.subscriptionId ?? ""; @@ -1128,6 +1149,7 @@ fn write_observable_helper(out: &mut String) { unsubscribe: () => {{ if (closed) return; closed = true; + stopForwarding(); raw?.unsubscribe(); }}, }}; @@ -1163,59 +1185,28 @@ fn write_stream_pair_helper(out: &mut String) { decodeItem: (payload: Uint8Array) => Item; }): ObservableLike { let used = false; - const source = createObservable({ transport, ids, payload, decodeItem }); + const source = createObservable({ + transport, + ids, + payload, + decodeItem, + onSubscribe(subscription) { + return requests.subscribe({ + next(request) { + transport.sendSubscriptionItem({ + ids, + subscriptionId: subscription.subscriptionId, + payload: encodeRequest(request), + }); + }, + }); + }, + }); const items: ObservableLike = { subscribe(observer: Partial> = {}): Subscription { if (used) throw new Error("channel is single-use: its one subscription is the operation"); used = true; - let requestSubscription: { unsubscribe(): void } | undefined; - let terminated = false; - const stopForwarding = () => { - const forwarding = requestSubscription; - requestSubscription = undefined; - forwarding?.unsubscribe(); - }; - const subscription = source.subscribe({ - next(value) { - observer.next?.(value); - }, - error(error) { - terminated = true; - stopForwarding(); - observer.error?.(error); - }, - complete() { - terminated = true; - stopForwarding(); - observer.complete?.(); - }, - }); - if (!terminated) { - try { - requestSubscription = requests.subscribe({ - next(request) { - transport.sendSubscriptionItem({ - ids, - subscriptionId: subscription.subscriptionId, - payload: encodeRequest(request), - }); - }, - }); - } catch (error) { - subscription.unsubscribe(); - throw error; - } - if (terminated) stopForwarding(); - } - return { - get subscriptionId() { - return subscription.subscriptionId; - }, - unsubscribe() { - stopForwarding(); - subscription.unsubscribe(); - }, - }; + return source.subscribe(observer); }, [OBSERVABLE_INTEROP as typeof Symbol.observable]() { return items; diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs index 8cbe659f..a0f4652d 100644 --- a/rust/crates/truapi-server/src/dispatcher.rs +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -58,13 +58,12 @@ pub struct RequestEntry { /// A registered subscription handler plus the discriminants its frames carry. pub struct SubscriptionEntry { ids: SubscriptionFrameIds, - handler: SubscriptionHandler, + handler: SubscriptionHandlerKind, } -/// A registered paired-stream handler plus its frame discriminants. -pub struct StreamPairEntry { - ids: SubscriptionFrameIds, - handler: StreamPairHandler, +enum SubscriptionHandlerKind { + OneWay(SubscriptionHandler), + Paired(StreamPairHandler), } /// Routes incoming protocol messages to registered handlers, keyed on the @@ -72,7 +71,6 @@ pub struct StreamPairEntry { pub struct Dispatcher { by_request: HashMap, by_start: HashMap, - by_pair_start: HashMap, pair_receive_ids: HashSet, stop_ids: HashSet, subscriptions: SubscriptionManager, @@ -97,7 +95,6 @@ impl Dispatcher { Self { by_request: HashMap::new(), by_start: HashMap::new(), - by_pair_start: HashMap::new(), pair_receive_ids: HashSet::new(), stop_ids: HashSet::new(), subscriptions: SubscriptionManager::new(spawner), @@ -115,7 +112,7 @@ impl Dispatcher { &mut self, ids: SubscriptionFrameIds, handler: F, - ) -> Option + ) -> Option where F: Fn( String, @@ -128,11 +125,11 @@ impl Dispatcher { { self.stop_ids.insert(ids.stop_id); self.pair_receive_ids.insert(ids.receive_id); - self.by_pair_start.insert( + self.by_start.insert( ids.start_id, - StreamPairEntry { + SubscriptionEntry { ids, - handler: Arc::new(handler), + handler: SubscriptionHandlerKind::Paired(Arc::new(handler)), }, ) } @@ -176,7 +173,7 @@ impl Dispatcher { ids.start_id, SubscriptionEntry { ids, - handler: Arc::new(handler), + handler: SubscriptionHandlerKind::OneWay(Arc::new(handler)), }, ) } @@ -200,39 +197,27 @@ impl Dispatcher { value, }, }); - } else if let Some(entry) = self.by_pair_start.get(&id) { - let (token, requests) = self - .subscriptions - .reserve_pair(message.request_id.clone(), entry.ids.receive_id); - let request_id = message.request_id.clone(); - match (entry.handler)(request_id, message.payload.value, requests).await { - Ok(stream) => { - self.subscriptions.activate( - token, - entry.ids.receive_id, - entry.ids.interrupt_id, - stream, - transport, - ); - } - Err(err_bytes) => { - self.subscriptions.cancel_reservation(token); - transport.send(ProtocolMessage { - request_id: message.request_id, - payload: Payload { - id: entry.ids.interrupt_id, - value: err_bytes, - }, - }); - } - } } else if let Some(entry) = self.by_start.get(&id) { // Reserve the slot before awaiting the handler so a `_stop` // arriving while the handler resolves cancels the pending // subscription instead of racing the registration. - let token = self.subscriptions.reserve(message.request_id.clone()); let request_id = message.request_id.clone(); - match (entry.handler)(request_id, message.payload.value).await { + let (token, result) = match &entry.handler { + SubscriptionHandlerKind::OneWay(handler) => { + let token = self.subscriptions.reserve(request_id.clone()); + (token, handler(request_id, message.payload.value).await) + } + SubscriptionHandlerKind::Paired(handler) => { + let (token, requests) = self + .subscriptions + .reserve_pair(request_id.clone(), entry.ids.receive_id); + ( + token, + handler(request_id, message.payload.value, requests).await, + ) + } + }; + match result { Ok(stream) => { self.subscriptions.activate( token, diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 97bb3d87..d7f9047b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -91,17 +91,12 @@ impl PairingHostRuntime { { let platform: Arc = platform; let services = RuntimeServices::new( - platform.clone(), + platform, config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner.clone(), ); - let pairing_host = PairingHostRole::new(services.clone(), config); - pairing_host.clone().start_session_store_sync(spawner); - Self { - services, - pairing_host, - } + Self::from_services(services, config, spawner) } /// Build a long-lived pairing host with a native Chat adapter. @@ -117,11 +112,19 @@ impl PairingHostRuntime { let platform: Arc = platform; let services = RuntimeServices::new_with_chat( platform, - Some(chat), + chat, config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner.clone(), ); + Self::from_services(services, config, spawner) + } + + fn from_services( + services: Arc, + config: PairingHostConfig, + spawner: Spawner, + ) -> Self { let pairing_host = PairingHostRole::new(services.clone(), config); pairing_host.clone().start_session_store_sync(spawner); Self { @@ -282,16 +285,12 @@ impl SigningHostRuntime { { let platform: Arc = platform; let services = RuntimeServices::new( - platform.clone(), + platform, config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner, ); - let signing_host = SigningHostRole::new(services.clone()); - Self { - services, - signing_host, - } + Self::from_services(services) } /// Build a long-lived signing host with a native Chat adapter. @@ -307,11 +306,15 @@ impl SigningHostRuntime { let platform: Arc = platform; let services = RuntimeServices::new_with_chat( platform, - Some(chat), + chat, config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner, ); + Self::from_services(services) + } + + fn from_services(services: Arc) -> Self { let signing_host = SigningHostRole::new(services.clone()); Self { services, @@ -381,11 +384,6 @@ impl SigningHostRuntime { self.signing_host.session_state().current().is_some() } - /// Return whether this host installed a native Chat adapter. - pub fn supports_chat(&self) -> bool { - self.services.chat.is_some() - } - /// Disconnect the active account-authority session. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_session"))] pub async fn disconnect_session(&self) { @@ -455,10 +453,7 @@ impl HostAdmin { authority.clone(), product, )); - Self { - authority, - product_runtime, - } + Self::from_product_runtime(authority, product_runtime) } /// Build an admin handle with adapters scoped to one executable @@ -477,6 +472,13 @@ impl HostAdmin { authority.clone(), product, )); + Self::from_product_runtime(authority, product_runtime) + } + + fn from_product_runtime( + authority: Arc, + product_runtime: Arc, + ) -> Self { Self { authority, product_runtime, @@ -577,15 +579,19 @@ pub struct ProductRuntimeControl { } impl ProductRuntimeControl { + fn runtime(&self) -> Result<&ProductRuntimeHost, ProductRuntimeError> { + if self.disposed.load(Ordering::Acquire) { + return Err(ProductRuntimeError::Closed); + } + Ok(&self.runtime) + } + /// Publish a native Chat action to this connection. pub fn publish_chat_action( &self, action: v01::HostChatActionSubscribeItem, ) -> Result<(), ProductRuntimeError> { - if self.disposed.load(Ordering::Acquire) { - return Err(ProductRuntimeError::Closed); - } - self.runtime.publish_chat_action(action) + self.runtime()?.publish_chat_action(action) } /// Request custom-message UI from this connection's product renderer. @@ -595,10 +601,7 @@ impl ProductRuntimeControl { message_type: String, payload: Vec, ) -> Result, ProductRuntimeError> { - if self.disposed.load(Ordering::Acquire) { - return Err(ProductRuntimeError::Closed); - } - self.runtime + self.runtime()? .render_custom_message(message_id, message_type, payload) } } @@ -629,24 +632,8 @@ impl ProductRuntime { product: ProductContext, sink: Arc, ) -> Self { - let disposed = Arc::new(AtomicBool::new(false)); - let transport = Arc::new(SinkTransport { - sink, - disposed: disposed.clone(), - }); let admin = HostAdmin::new(services.clone(), authority.clone(), product); - Self { - core: TrUApiCore::from_product_runtime( - admin.product_runtime.clone(), - services.spawner.clone(), - authority.session_state(), - ), - admin, - transport, - disposed, - in_flight: Mutex::new(HashMap::new()), - next_dispatch_id: AtomicU64::new(0), - } + Self::from_admin(services, authority, admin, sink) } /// Build one connection using execution-scoped platform adapters while @@ -659,11 +646,6 @@ impl ProductRuntime { chat: Option>, sink: Arc, ) -> Self { - let disposed = Arc::new(AtomicBool::new(false)); - let transport = Arc::new(SinkTransport { - sink, - disposed: disposed.clone(), - }); let admin = HostAdmin::new_with_platform( services.clone(), authority.clone(), @@ -671,6 +653,20 @@ impl ProductRuntime { platform, chat, ); + Self::from_admin(services, authority, admin, sink) + } + + fn from_admin( + services: Arc, + authority: Arc, + admin: HostAdmin, + sink: Arc, + ) -> Self { + let disposed = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(SinkTransport { + sink, + disposed: disposed.clone(), + }); Self { core: TrUApiCore::from_product_runtime( admin.product_runtime.clone(), @@ -721,17 +717,6 @@ impl ProductRuntime { Ok(()) } - /// Publish a native Chat action to this connection's action subscription. - pub fn publish_chat_action( - &self, - action: v01::HostChatActionSubscribeItem, - ) -> Result<(), ProductRuntimeError> { - if self.disposed.load(Ordering::Acquire) { - return Err(ProductRuntimeError::Closed); - } - self.admin.product_runtime.publish_chat_action(action) - } - /// Return a cloneable native control handle bound to this connection. pub fn control(&self) -> ProductRuntimeControl { ProductRuntimeControl { @@ -740,21 +725,6 @@ impl ProductRuntime { } } - /// Request native UI for a stored custom message from the product renderer. - pub fn render_custom_message( - &self, - message_id: String, - message_type: String, - payload: Vec, - ) -> Result, ProductRuntimeError> { - if self.disposed.load(Ordering::Acquire) { - return Err(ProductRuntimeError::Closed); - } - self.admin - .product_runtime - .render_custom_message(message_id, message_type, payload) - } - /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when /// the session has channel material, then clears in-memory and persisted /// session state. @@ -900,7 +870,7 @@ mod tests { ); assert!(matches!( - runtime.publish_chat_action(text_action("hello")), + runtime.control().publish_chat_action(text_action("hello")), Err(ProductRuntimeError::Denied) )); } @@ -917,7 +887,9 @@ mod tests { ); assert!(matches!( - runtime.render_custom_message("message".into(), "vote".into(), vec![]), + runtime + .control() + .render_custom_message("message".into(), "vote".into(), vec![]), Err(ProductRuntimeError::Denied) )); } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 8e4b3c54..3306246b 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -47,6 +47,34 @@ struct NativeProductControlState { pending_chat_actions: VecDeque, } +#[cfg(feature = "ws-bridge")] +impl NativeProductControlState { + fn publish_chat_action( + &mut self, + action: v01::HostChatActionSubscribeItem, + ) -> Result<(), NativeChatError> { + if let Some(control) = self.control.as_ref() { + match control.publish_chat_action(action.clone()) { + Ok(()) => return Ok(()), + Err(crate::ProductRuntimeError::Closed) => self.control = None, + Err(error) => return Err(error.into()), + } + } + if self.pending_chat_actions.len() == NATIVE_CHAT_ACTION_BUFFER_CAPACITY { + return Err(NativeChatError::BufferFull); + } + self.pending_chat_actions.push_back(action); + Ok(()) + } + + fn attach(&mut self, control: crate::ProductRuntimeControl) { + for action in self.pending_chat_actions.drain(..) { + let _ = control.publish_chat_action(action); + } + self.control = Some(control); + } +} + /// Native-friendly storage error. Mirrors the v0.1 wire shape so the /// callback surface stays SCALE-free. #[derive(Debug, Clone, thiserror::Error, uniffi::Error)] @@ -458,10 +486,8 @@ struct NativeResolvedHostRuntimeConfig { #[derive(Debug)] struct NativeResolvedRuntimeConfig { - signing: SigningHostConfig, + host: NativeResolvedHostRuntimeConfig, product: ProductContext, - local_session_secret: Option>, - local_session_lite_username: Option, } /// Native runtime config validation error. @@ -521,40 +547,38 @@ impl TryFrom for NativeResolvedRuntimeConfig { type Error = NativeRuntimeConfigError; fn try_from(config: NativeRuntimeConfig) -> Result { - let people_chain_genesis_hash = - <[u8; 32]>::try_from(config.people_chain_genesis_hash.as_slice()).map_err(|_| { - NativeRuntimeConfigError::InvalidPeopleChainGenesisHash { - actual: config.people_chain_genesis_hash.len() as u64, - } - })?; - let bulletin_chain_genesis_hash = - <[u8; 32]>::try_from(config.bulletin_chain_genesis_hash.as_slice()).map_err(|_| { - NativeRuntimeConfigError::InvalidBulletinChainGenesisHash { - actual: config.bulletin_chain_genesis_hash.len() as u64, - } - })?; - let product = - ProductContext::new_with_execution(config.product_id, config.execution_kind.into()) - .map_err(NativeRuntimeConfigError::from)?; - let signing = SigningHostConfig::new( - HostInfo { - name: config.host_name, - icon: config.host_icon, - version: config.host_version, - }, - PlatformInfo { - kind: config.platform_type, - version: config.platform_version, - }, + let NativeRuntimeConfig { + product_id, + execution_kind, + host_name, + host_icon, + host_version, + platform_type, + platform_version, people_chain_genesis_hash, bulletin_chain_genesis_hash, - )?; - Ok(Self { - signing, - product, - local_session_secret: config.local_session_secret, - local_session_lite_username: config.local_session_lite_username, - }) + local_session_secret, + local_session_lite_username, + pairing_deeplink_scheme: _, + } = config; + let host: NativeResolvedHostRuntimeConfig = NativeHostRuntimeConfig { + host_name, + host_icon, + host_version, + platform_type, + platform_version, + people_chain_genesis_hash, + bulletin_chain_genesis_hash, + local_session_secret, + local_session_lite_username, + } + .try_into()?; + let product = NativeProductExecutionConfig { + product_id, + execution_kind, + } + .try_into()?; + Ok(Self { host, product }) } } @@ -787,25 +811,18 @@ pub struct NativeTrUApiHostRuntime { events: Arc, #[cfg(feature = "ws-bridge")] spawner: Spawner, - _callbacks: Arc, chat_executions: Mutex>>, } -#[uniffi::export] impl NativeTrUApiHostRuntime { - /// Construct one host-level runtime and optionally activate its local session. - #[uniffi::constructor] - pub fn with_runtime_config( - callbacks: Box, - runtime_config: NativeHostRuntimeConfig, + fn from_resolved( + callbacks: Arc, + runtime_config: NativeResolvedHostRuntimeConfig, + log_marker: &str, + log_detail: &str, ) -> Result, NativeRuntimeConfigError> { crate::logging::init(); - let runtime_config: NativeResolvedHostRuntimeConfig = runtime_config.try_into()?; - let callbacks: Arc = callbacks.into(); - callbacks.on_core_log( - "truapi.native.host_runtime.boot".to_string(), - "host runtime ready".to_string(), - ); + callbacks.on_core_log(log_marker.to_string(), log_detail.to_string()); let events = Arc::new(NativeEventBus::default()); let platform = Arc::new(CallbackPlatform { callbacks: callbacks.clone(), @@ -831,19 +848,15 @@ impl NativeTrUApiHostRuntime { events, #[cfg(feature = "ws-bridge")] spawner, - _callbacks: callbacks, chat_executions: Mutex::new(HashMap::new()), })) } - /// Open a connection-scoped execution with immutable trusted context. - pub fn open_product_execution( + fn open_product_execution_with_callbacks( &self, - callbacks: Box, - execution_config: NativeProductExecutionConfig, - ) -> Result, NativeRuntimeConfigError> { - let product: ProductContext = execution_config.try_into()?; - let callbacks: Arc = callbacks.into(); + callbacks: Arc, + product: ProductContext, + ) -> Arc { let events = Arc::new(NativeEventBus::default()); let concrete_platform = Arc::new(CallbackPlatform { callbacks: callbacks.clone(), @@ -852,7 +865,7 @@ impl NativeTrUApiHostRuntime { let platform: Arc = concrete_platform.clone(); let chat: Option> = callbacks .chat_supported() - .then(|| concrete_platform.clone() as Arc); + .then_some(concrete_platform as Arc); let execution = Arc::new(NativeProductExecution { runtime: self.runtime.clone(), product: product.clone(), @@ -882,7 +895,37 @@ impl NativeTrUApiHostRuntime { } } - Ok(execution) + execution + } +} + +#[uniffi::export] +impl NativeTrUApiHostRuntime { + /// Construct one host-level runtime and optionally activate its local session. + #[uniffi::constructor] + pub fn with_runtime_config( + callbacks: Box, + runtime_config: NativeHostRuntimeConfig, + ) -> Result, NativeRuntimeConfigError> { + let runtime_config: NativeResolvedHostRuntimeConfig = runtime_config.try_into()?; + let callbacks: Arc = callbacks.into(); + Self::from_resolved( + callbacks, + runtime_config, + "truapi.native.host_runtime.boot", + "host runtime ready", + ) + } + + /// Open a connection-scoped execution with immutable trusted context. + pub fn open_product_execution( + &self, + callbacks: Box, + execution_config: NativeProductExecutionConfig, + ) -> Result, NativeRuntimeConfigError> { + let product: ProductContext = execution_config.try_into()?; + let callbacks: Arc = callbacks.into(); + Ok(self.open_product_execution_with_callbacks(callbacks, product)) } /// Core-owned logout for the process-wide authentication session. @@ -939,6 +982,47 @@ pub struct NativeProductExecution { product_control: Arc>, } +impl NativeProductExecution { + fn admin(&self) -> crate::HostAdmin { + self.runtime.product_admin_with_platform( + self.product.clone(), + self.platform.clone(), + self.chat.clone(), + ) + } + + fn require_chat(&self) -> Result<(), NativeChatError> { + if self.closed.load(Ordering::Acquire) { + return Err(NativeChatError::Closed); + } + if self.product.execution_kind != ProductExecutionKind::Chat + || !self.runtime.has_active_session() + { + return Err(NativeChatError::Denied); + } + if self.chat.is_none() { + return Err(NativeChatError::Unsupported); + } + Ok(()) + } + + #[cfg(feature = "ws-bridge")] + fn stop_bridge(&self) { + if let Some(mut bridge) = self + .bridge + .lock() + .expect("native product bridge mutex poisoned") + .take() + { + bridge.stop(); + } + *self + .product_control + .lock() + .expect("native product control mutex poisoned") = NativeProductControlState::default(); + } +} + #[uniffi::export] impl NativeProductExecution { /// Read a product-scoped permission authorization without prompting. @@ -947,12 +1031,8 @@ impl NativeProductExecution { payload: Vec, ) -> Result { let request = decode_permission_authorization_request(&payload)?; - let admin = self.runtime.product_admin_with_platform( - self.product.clone(), - self.platform.clone(), - self.chat.clone(), - ); - let status = futures::executor::block_on(admin.permission_authorization_status(request))?; + let status = + futures::executor::block_on(self.admin().permission_authorization_status(request))?; Ok(status.into()) } @@ -963,13 +1043,9 @@ impl NativeProductExecution { status: NativePermissionAuthorizationStatus, ) -> Result<(), HostRejection> { let request = decode_permission_authorization_request(&payload)?; - let admin = self.runtime.product_admin_with_platform( - self.product.clone(), - self.platform.clone(), - self.chat.clone(), - ); futures::executor::block_on( - admin.set_permission_authorization_status(request, status.into()), + self.admin() + .set_permission_authorization_status(request, status.into()), )?; Ok(()) } @@ -991,37 +1067,15 @@ impl NativeProductExecution { /// Publish one native Chat action, buffering it until the connection opens. pub fn publish_chat_action(&self, action: NativeChatAction) -> Result<(), NativeChatError> { - if self.closed.load(Ordering::Acquire) { - return Err(NativeChatError::Closed); - } - if self.product.execution_kind != ProductExecutionKind::Chat - || !self.runtime.has_active_session() - { - return Err(NativeChatError::Denied); - } - if self.chat.is_none() { - return Err(NativeChatError::Unsupported); - } + self.require_chat()?; #[cfg(feature = "ws-bridge")] { let action: v01::HostChatActionSubscribeItem = action.into(); - let mut state = self - .product_control + self.product_control .lock() - .expect("native product control mutex poisoned"); - if let Some(control) = state.control.as_ref() { - match control.publish_chat_action(action.clone()) { - Ok(()) => return Ok(()), - Err(crate::ProductRuntimeError::Closed) => state.control = None, - Err(error) => return Err(error.into()), - } - } - if state.pending_chat_actions.len() == NATIVE_CHAT_ACTION_BUFFER_CAPACITY { - return Err(NativeChatError::BufferFull); - } - state.pending_chat_actions.push_back(action); - Ok(()) + .expect("native product control mutex poisoned") + .publish_chat_action(action) } #[cfg(not(feature = "ws-bridge"))] { @@ -1038,17 +1092,7 @@ impl NativeProductExecution { payload: Vec, observer: Box, ) -> Result, NativeChatError> { - if self.closed.load(Ordering::Acquire) { - return Err(NativeChatError::Closed); - } - if self.product.execution_kind != ProductExecutionKind::Chat - || !self.runtime.has_active_session() - { - return Err(NativeChatError::Denied); - } - if self.chat.is_none() { - return Err(NativeChatError::Unsupported); - } + self.require_chat()?; #[cfg(feature = "ws-bridge")] { let control = self @@ -1073,27 +1117,11 @@ impl NativeProductExecution { /// Permanently close this executable and all of its connection state. pub fn close(&self) { - #[cfg(feature = "ws-bridge")] - { - if self.closed.swap(true, Ordering::AcqRel) { - return; - } - if let Some(mut bridge) = self - .bridge - .lock() - .expect("native product bridge mutex poisoned") - .take() - { - bridge.stop(); - } - *self - .product_control - .lock() - .expect("native product control mutex poisoned") = - NativeProductControlState::default(); + if self.closed.swap(true, Ordering::AcqRel) { + return; } - #[cfg(not(feature = "ws-bridge"))] - self.closed.swap(true, Ordering::AcqRel); + #[cfg(feature = "ws-bridge")] + self.stop_bridge(); } } @@ -1133,13 +1161,10 @@ impl NativeProductExecution { sink, ); let control = product_runtime.control(); - let mut state = product_control + product_control .lock() - .expect("native product control mutex poisoned"); - for action in state.pending_chat_actions.drain(..) { - let _ = control.publish_chat_action(action); - } - state.control = Some(control); + .expect("native product control mutex poisoned") + .attach(control); product_runtime }); let (bridge, endpoint) = WsBridge::start(bind_port, runtime_factory, logger)?; @@ -1149,18 +1174,7 @@ impl NativeProductExecution { /// Stop the active bridge while leaving the execution reusable. pub fn stop_ws_bridge(&self) { - if let Some(mut bridge) = self - .bridge - .lock() - .expect("native product bridge mutex poisoned") - .take() - { - bridge.stop(); - } - *self - .product_control - .lock() - .expect("native product control mutex poisoned") = NativeProductControlState::default(); + self.stop_bridge(); } } @@ -1175,17 +1189,8 @@ impl Drop for NativeProductExecution { /// [`NativeProductExecution`]. #[derive(uniffi::Object)] pub struct NativeTrUApiCore { - runtime: Arc, - product: ProductContext, - events: Arc, - #[cfg(feature = "ws-bridge")] - spawner: Spawner, - #[cfg(feature = "ws-bridge")] - callbacks: Arc, - #[cfg(feature = "ws-bridge")] - bridge: std::sync::Mutex>, - #[cfg(feature = "ws-bridge")] - product_control: Arc>, + host: Arc, + execution: Arc, } #[uniffi::export] @@ -1211,7 +1216,7 @@ impl NativeTrUApiCore { /// Blocks the calling thread until the disconnect completes, so call it off /// the host's main/UI thread. pub fn disconnect(&self) { - futures::executor::block_on(self.runtime.disconnect_session()); + self.host.disconnect(); } /// Notify this core that host-global session storage changed outside a @@ -1221,8 +1226,7 @@ impl NativeTrUApiCore { /// memory, so there is no session-store sync loop to wake. Retained so /// hosts written against the pairing-host surface still link. pub fn notify_session_store_changed(&self) { - // Signing hosts own the active local session in memory. There is no - // pairing-host session-store sync loop to notify. + self.host.notify_session_store_changed(); } /// Cancel an in-flight pairing login. @@ -1233,8 +1237,7 @@ impl NativeTrUApiCore { /// changes nothing. Retained so hosts written against the pairing-host /// surface still link. pub fn cancel_login(&self) { - // Signing hosts do not perform SSO pairing when products call - // request_login; a locally activated session returns AlreadyConnected. + self.host.cancel_login(); } /// Read a stored permission authorization status without prompting. @@ -1246,10 +1249,7 @@ impl NativeTrUApiCore { &self, payload: Vec, ) -> Result { - let request = decode_permission_authorization_request(&payload)?; - let admin = self.runtime.product_admin(self.product.clone()); - let status = futures::executor::block_on(admin.permission_authorization_status(request))?; - Ok(status.into()) + self.execution.permission_authorization_status(payload) } /// Update a stored permission authorization status. Passing @@ -1263,12 +1263,8 @@ impl NativeTrUApiCore { payload: Vec, status: NativePermissionAuthorizationStatus, ) -> Result<(), HostRejection> { - let request = decode_permission_authorization_request(&payload)?; - let admin = self.runtime.product_admin(self.product.clone()); - futures::executor::block_on( - admin.set_permission_authorization_status(request, status.into()), - )?; - Ok(()) + self.execution + .set_permission_authorization_status(payload, status) } /// Activate or replace the local signing-host session from host-held @@ -1281,16 +1277,12 @@ impl NativeTrUApiCore { secret: Vec, lite_username: Option, ) -> Result<(), HostRejection> { - futures::executor::block_on( - self.runtime - .activate_local_session_with_identity(secret, lite_username), - ) - .map_err(Into::into) + self.host.activate_local_session(secret, lite_username) } /// Push a host theme update to active TrUAPI theme subscriptions. pub fn notify_theme_changed(&self, theme: HostTheme) { - self.events.notify_theme_changed(theme.into()); + self.execution.notify_theme_changed(theme); } /// Push a preimage lookup update to active subscriptions for `key`. @@ -1298,61 +1290,27 @@ impl NativeTrUApiCore { /// `value == None` represents a known miss; `Some(bytes)` represents the /// current preimage value. pub fn notify_preimage_changed(&self, key: Vec, value: Option>) { - self.events.notify_preimage_changed(&key, value); + self.execution.notify_preimage_changed(key, value); } /// Push a JSON-RPC response from a native chain connection into the core. pub fn notify_chain_response(&self, connection_id: u32, json: String) { - self.events.notify_chain_response(connection_id, json); + self.host.notify_chain_response(connection_id, json); } /// Notify the core that a native chain connection closed externally. pub fn notify_chain_closed(&self, connection_id: u32) { - self.events.notify_chain_closed(connection_id); + self.host.notify_chain_closed(connection_id); } /// Push a complete replacement of the current native Chat room list. pub fn notify_chat_rooms_changed(&self, rooms: Vec) { - self.events.notify_chat_rooms_changed(rooms); + self.execution.notify_chat_rooms_changed(rooms); } /// Publish one native Chat action to the connected product worker. pub fn publish_chat_action(&self, action: NativeChatAction) -> Result<(), NativeChatError> { - if self.product.execution_kind != ProductExecutionKind::Chat - || !self.runtime.has_active_session() - { - return Err(NativeChatError::Denied); - } - if !self.runtime.supports_chat() { - return Err(NativeChatError::Unsupported); - } - - #[cfg(feature = "ws-bridge")] - { - let action: v01::HostChatActionSubscribeItem = action.into(); - let mut state = self - .product_control - .lock() - .expect("native product control mutex poisoned"); - if let Some(control) = state.control.as_ref() { - match control.publish_chat_action(action.clone()) { - Ok(()) => return Ok(()), - Err(crate::ProductRuntimeError::Closed) => state.control = None, - Err(error) => return Err(error.into()), - } - } - - if state.pending_chat_actions.len() == NATIVE_CHAT_ACTION_BUFFER_CAPACITY { - return Err(NativeChatError::BufferFull); - } - state.pending_chat_actions.push_back(action); - Ok(()) - } - #[cfg(not(feature = "ws-bridge"))] - { - let _ = action; - Err(NativeChatError::NotConnected) - } + self.execution.publish_chat_action(action) } /// Request typed native UI for one stored custom Chat message. @@ -1363,35 +1321,8 @@ impl NativeTrUApiCore { payload: Vec, observer: Box, ) -> Result, NativeChatError> { - if self.product.execution_kind != ProductExecutionKind::Chat - || !self.runtime.has_active_session() - { - return Err(NativeChatError::Denied); - } - if !self.runtime.supports_chat() { - return Err(NativeChatError::Unsupported); - } - - #[cfg(feature = "ws-bridge")] - { - let control = self - .product_control - .lock() - .expect("native product control mutex poisoned") - .control - .clone() - .ok_or(NativeChatError::NotConnected)?; - let stream = control - .render_custom_message(message_id, message_type, payload) - .map_err(NativeChatError::from)?; - let observer: Arc = observer.into(); - Ok(observe_renderer(stream, observer, self.spawner.clone())) - } - #[cfg(not(feature = "ws-bridge"))] - { - let _ = (message_id, message_type, payload, observer); - Err(NativeChatError::NotConnected) - } + self.execution + .render_custom_message(message_id, message_type, payload, observer) } } @@ -1416,55 +1347,15 @@ fn native_core_from_platform_config( callbacks: Box, runtime_config: NativeResolvedRuntimeConfig, ) -> Result, NativeRuntimeConfigError> { - crate::logging::init(); let callbacks: Arc = callbacks.into(); - callbacks.on_core_log( - "truapi.native.core.boot".to_string(), - "core ready".to_string(), - ); - - let events = Arc::new(NativeEventBus::default()); - let platform = Arc::new(CallbackPlatform { - callbacks: callbacks.clone(), - events: events.clone(), - }); - let spawner = native_thread_pool_spawner(&callbacks); - let runtime = if callbacks.chat_supported() { - Arc::new(SigningHostRuntime::new_with_chat( - platform.clone(), - platform, - runtime_config.signing, - spawner.clone(), - )) - } else { - Arc::new(SigningHostRuntime::new( - platform, - runtime_config.signing, - spawner.clone(), - )) - }; - - if let Some(secret) = runtime_config.local_session_secret { - futures::executor::block_on(runtime.activate_local_session_with_identity( - secret, - runtime_config.local_session_lite_username, - )) - .map_err(|err| NativeRuntimeConfigError::LocalSessionActivation { reason: err.reason })?; - } - - Ok(Arc::new(NativeTrUApiCore { - runtime, - product: runtime_config.product, - events, - #[cfg(feature = "ws-bridge")] - spawner, - #[cfg(feature = "ws-bridge")] - callbacks, - #[cfg(feature = "ws-bridge")] - bridge: std::sync::Mutex::new(None), - #[cfg(feature = "ws-bridge")] - product_control: Arc::new(Mutex::new(NativeProductControlState::default())), - })) + let host = NativeTrUApiHostRuntime::from_resolved( + callbacks.clone(), + runtime_config.host, + "truapi.native.core.boot", + "core ready", + )?; + let execution = host.open_product_execution_with_callbacks(callbacks, runtime_config.product); + Ok(Arc::new(NativeTrUApiCore { host, execution })) } #[cfg(feature = "ws-bridge")] @@ -1473,45 +1364,12 @@ impl NativeTrUApiCore { /// Start the localhost WebSocket bridge. Returns the descriptor the /// host hands to the product so it can dial back in. pub fn start_ws_bridge(&self, bind_port: u16) -> Result { - let mut guard = self.bridge.lock().unwrap(); - if guard.is_some() { - return Err(WsBridgeStartError::AlreadyRunning); - } - let logger: BridgeLogger = { - let callbacks = self.callbacks.clone(); - Arc::new(move |marker: &str, detail: &str| { - callbacks.on_core_log(marker.to_string(), detail.to_string()); - }) - }; - let runtime = self.runtime.clone(); - let product = self.product.clone(); - let product_control = self.product_control.clone(); - let runtime_factory = Arc::new(move |sink| { - let product_runtime = runtime.product_runtime(product.clone(), sink); - let control = product_runtime.control(); - let mut state = product_control - .lock() - .expect("native product control mutex poisoned"); - for action in state.pending_chat_actions.drain(..) { - let _ = control.publish_chat_action(action); - } - state.control = Some(control); - product_runtime - }); - let (bridge, endpoint) = WsBridge::start(bind_port, runtime_factory, logger)?; - *guard = Some(bridge); - Ok(endpoint) + self.execution.start_ws_bridge(bind_port) } /// Stop the localhost WebSocket bridge (if running). pub fn stop_ws_bridge(&self) { - if let Some(mut bridge) = self.bridge.lock().unwrap().take() { - bridge.stop(); - } - *self - .product_control - .lock() - .expect("native product control mutex poisoned") = NativeProductControlState::default(); + self.execution.stop_ws_bridge(); } } diff --git a/rust/crates/truapi-server/src/native_renderer.rs b/rust/crates/truapi-server/src/native_renderer.rs index 4e254fae..8ca80675 100644 --- a/rust/crates/truapi-server/src/native_renderer.rs +++ b/rust/crates/truapi-server/src/native_renderer.rs @@ -430,30 +430,33 @@ impl NativeCustomRendererNode { } impl NativeCustomRendererNode { - fn component_modifiers(&self) -> &[v01::Modifier] { + fn component_parts(&self) -> (&[v01::Modifier], &[v01::CustomRendererNode]) { match &self.inner { - v01::CustomRendererNode::Box(component) => &component.modifiers, - v01::CustomRendererNode::Column(component) => &component.modifiers, - v01::CustomRendererNode::Row(component) => &component.modifiers, - v01::CustomRendererNode::Spacer(component) => &component.modifiers, - v01::CustomRendererNode::Text(component) => &component.modifiers, - v01::CustomRendererNode::Button(component) => &component.modifiers, - v01::CustomRendererNode::TextField(component) => &component.modifiers, - v01::CustomRendererNode::Nil | v01::CustomRendererNode::String { .. } => &[], + v01::CustomRendererNode::Box(component) => (&component.modifiers, &component.children), + v01::CustomRendererNode::Column(component) => { + (&component.modifiers, &component.children) + } + v01::CustomRendererNode::Row(component) => (&component.modifiers, &component.children), + v01::CustomRendererNode::Spacer(component) => { + (&component.modifiers, &component.children) + } + v01::CustomRendererNode::Text(component) => (&component.modifiers, &component.children), + v01::CustomRendererNode::Button(component) => { + (&component.modifiers, &component.children) + } + v01::CustomRendererNode::TextField(component) => { + (&component.modifiers, &component.children) + } + v01::CustomRendererNode::Nil | v01::CustomRendererNode::String { .. } => (&[], &[]), } } + fn component_modifiers(&self) -> &[v01::Modifier] { + self.component_parts().0 + } + fn component_children(&self) -> &[v01::CustomRendererNode] { - match &self.inner { - v01::CustomRendererNode::Box(component) => &component.children, - v01::CustomRendererNode::Column(component) => &component.children, - v01::CustomRendererNode::Row(component) => &component.children, - v01::CustomRendererNode::Spacer(component) => &component.children, - v01::CustomRendererNode::Text(component) => &component.children, - v01::CustomRendererNode::Button(component) => &component.children, - v01::CustomRendererNode::TextField(component) => &component.children, - v01::CustomRendererNode::Nil | v01::CustomRendererNode::String { .. } => &[], - } + self.component_parts().1 } } @@ -490,14 +493,7 @@ impl NativeCustomRendererSubscription { impl Drop for NativeCustomRendererSubscription { fn drop(&mut self) { - if let Some(abort) = self - .abort - .get_mut() - .expect("native renderer subscription mutex poisoned") - .take() - { - abort.abort(); - } + self.cancel(); } } @@ -509,7 +505,7 @@ pub(crate) fn observe_renderer( ) -> Arc { let (abort, registration) = AbortHandle::new_pair(); (spawner)(Box::pin(async move { - let completed = Abortable::new( + let _ = Abortable::new( async move { while let Some(inner) = stream.next().await { observer.on_update(Arc::new(NativeCustomRendererNode { inner })); @@ -519,7 +515,6 @@ pub(crate) fn observe_renderer( registration, ) .await; - let _ = completed; })); Arc::new(NativeCustomRendererSubscription { abort: Mutex::new(Some(abort)), diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 01cfa2ad..1b7b286f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1762,38 +1762,37 @@ impl Chain for ProductRuntimeHost { const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; impl ProductRuntimeHost { - fn chat_platform(&self) -> Result, CallError> { + fn native_chat_platform( + &self, + ) -> Result, crate::host_core::ProductRuntimeError> { if self.product.execution_kind != ProductExecutionKind::Chat { - return Err(CallError::Denied); + return Err(crate::host_core::ProductRuntimeError::Denied); } if self.authority.session_state().current().is_none() { - return Err(CallError::Denied); + return Err(crate::host_core::ProductRuntimeError::Denied); } - self.chat_platform.clone().ok_or(CallError::Unsupported) + self.chat_platform + .clone() + .ok_or(crate::host_core::ProductRuntimeError::Unsupported) } - fn chat_streams_available(&self) -> bool { - self.chat_platform::<()>().is_ok() + fn chat_platform(&self) -> Result, CallError> { + self.native_chat_platform().map_err(|error| match error { + crate::host_core::ProductRuntimeError::Denied => CallError::Denied, + crate::host_core::ProductRuntimeError::Unsupported => CallError::Unsupported, + _ => unreachable!("Chat platform policy only returns Denied or Unsupported"), + }) } - fn require_native_chat(&self) -> Result<(), crate::host_core::ProductRuntimeError> { - if self.product.execution_kind != ProductExecutionKind::Chat { - return Err(crate::host_core::ProductRuntimeError::Denied); - } - if self.authority.session_state().current().is_none() { - return Err(crate::host_core::ProductRuntimeError::Denied); - } - if self.chat_platform.is_none() { - return Err(crate::host_core::ProductRuntimeError::Unsupported); - } - Ok(()) + fn chat_streams_available(&self) -> bool { + self.chat_platform::<()>().is_ok() } pub(crate) fn publish_chat_action( &self, action: v01::HostChatActionSubscribeItem, ) -> Result<(), crate::host_core::ProductRuntimeError> { - self.require_native_chat()?; + self.native_chat_platform()?; self.chat .publish_action(HostChatActionSubscribeItem::V1(action)) } @@ -1804,7 +1803,7 @@ impl ProductRuntimeHost { message_type: String, payload: Vec, ) -> Result, crate::host_core::ProductRuntimeError> { - self.require_native_chat()?; + self.native_chat_platform()?; self.chat .render_custom_message(message_id, message_type, payload) } diff --git a/rust/crates/truapi-server/src/runtime/chat.rs b/rust/crates/truapi-server/src/runtime/chat.rs index ac0923a4..046b1739 100644 --- a/rust/crates/truapi-server/src/runtime/chat.rs +++ b/rust/crates/truapi-server/src/runtime/chat.rs @@ -22,6 +22,7 @@ struct RendererState { renders: HashMap>, } +#[derive(Default)] struct State { actions: Option>, action_buffer: VecDeque, @@ -30,18 +31,6 @@ struct State { closed: bool, } -impl Default for State { - fn default() -> Self { - Self { - actions: None, - action_buffer: VecDeque::new(), - renderer: None, - next_renderer_generation: 1, - closed: false, - } - } -} - /// Mutable Chat protocol state owned by one product connection. pub(crate) struct ChatConnection { state: Arc>, @@ -74,15 +63,16 @@ impl ChatConnection { /// Publish one native action, buffering it until the product subscribes. pub(crate) fn publish_action( &self, - action: HostChatActionSubscribeItem, + mut action: HostChatActionSubscribeItem, ) -> Result<(), ProductRuntimeError> { let mut state = self.state.lock().expect("chat state mutex poisoned"); if state.closed { return Err(ProductRuntimeError::Closed); } if let Some(sender) = state.actions.as_ref() { - if sender.unbounded_send(action.clone()).is_ok() { - return Ok(()); + match sender.unbounded_send(action) { + Ok(()) => return Ok(()), + Err(error) => action = error.into_inner(), } state.actions = None; } @@ -125,22 +115,19 @@ impl ChatConnection { else { break; }; + let ProductChatCustomMessageRenderChannelRequest::V1(request) = request; match request { - ProductChatCustomMessageRenderChannelRequest::V1( - v01::ProductChatCustomMessageRenderChannelRequest::Update { - message_id, - node, - }, - ) => { + v01::ProductChatCustomMessageRenderChannelRequest::Update { + message_id, + node, + } => { if let Some(sender) = renderer.renders.get(&message_id) && sender.unbounded_send(node).is_err() { renderer.renders.remove(&message_id); } } - ProductChatCustomMessageRenderChannelRequest::V1( - v01::ProductChatCustomMessageRenderChannelRequest::Failed { message_id }, - ) => { + v01::ProductChatCustomMessageRenderChannelRequest::Failed { message_id } => { renderer.renders.remove(&message_id); } } diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 10768988..c3330d61 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -57,7 +57,7 @@ impl RuntimeServices { bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, ) -> Arc { - Self::new_with_chat( + Self::build( platform, None, people_chain_genesis_hash, @@ -66,8 +66,24 @@ impl RuntimeServices { ) } - /// Build role-neutral runtime services with an optional native Chat adapter. + /// Build role-neutral runtime services with a native Chat adapter. pub(crate) fn new_with_chat( + platform: Arc, + chat: Arc, + people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], + spawner: Spawner, + ) -> Arc { + Self::build( + platform, + Some(chat), + people_chain_genesis_hash, + bulletin_chain_genesis_hash, + spawner, + ) + } + + fn build( platform: Arc, chat: Option>, people_chain_genesis_hash: [u8; 32], diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs index 502616e2..6e47f450 100644 --- a/rust/crates/truapi-server/src/subscription.rs +++ b/rust/crates/truapi-server/src/subscription.rs @@ -93,9 +93,22 @@ enum Slot { /// Reserved by the dispatcher before its `_start` handler resolved. /// `cancelled` flips to `true` if a `_stop` arrives in that window so /// activation aborts instead of leaking an unstoppable stream. - Pending { generation: u64, cancelled: bool }, + Pending { + generation: u64, + cancelled: bool, + request_sender: Option, + }, /// A live subscription with its cancellation handle. - Live { generation: u64, cancel: StopFn }, + Live { + generation: u64, + cancel: StopFn, + request_sender: Option, + }, +} + +struct RequestSender { + receive_id: u8, + sender: mpsc::UnboundedSender>, } /// Handle returned by [`SubscriptionManager::reserve`] and presented back to @@ -107,12 +120,9 @@ pub struct ReservationToken { generation: u64, } -type RequestSenderMap = HashMap>)>; - /// Manages active subscriptions on the server side. pub struct SubscriptionManager { active: Arc>>, - request_senders: Arc>, next_generation: Arc, spawner: Spawner, } @@ -122,7 +132,6 @@ impl SubscriptionManager { pub fn new(spawner: Spawner) -> Self { Self { active: Arc::new(Mutex::new(HashMap::new())), - request_senders: Arc::new(Mutex::new(HashMap::new())), next_generation: Arc::new(AtomicU64::new(0)), spawner, } @@ -133,14 +142,22 @@ impl SubscriptionManager { /// replaced (re-subscribe semantics). A `_stop` arriving before /// [`activate`](Self::activate) flips the reservation to cancelled. pub fn reserve(&self, request_id: String) -> ReservationToken { + self.reserve_with_request_sender(request_id, None) + } + + fn reserve_with_request_sender( + &self, + request_id: String, + request_sender: Option, + ) -> ReservationToken { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); - self.request_senders.lock().unwrap().remove(&request_id); let mut active = self.active.lock().unwrap(); if let Some(Slot::Live { cancel, .. }) = active.insert( request_id.clone(), Slot::Pending { generation, cancelled: false, + request_sender, }, ) { cancel(); @@ -157,12 +174,9 @@ impl SubscriptionManager { request_id: String, receive_id: u8, ) -> (ReservationToken, SubscriptionRequestStream) { - let token = self.reserve(request_id.clone()); let (sender, receiver) = mpsc::unbounded(); - self.request_senders - .lock() - .unwrap() - .insert(request_id, (token.generation, receive_id, sender)); + let token = self + .reserve_with_request_sender(request_id, Some(RequestSender { receive_id, sender })); (token, Box::pin(receiver)) } @@ -177,10 +191,6 @@ impl SubscriptionManager { if owned { active.remove(&token.request_id); } - drop(active); - if owned { - self.remove_request_sender(&token.request_id, token.generation); - } } /// Activate a reserved subscription with its stream, forwarding stream @@ -210,18 +220,20 @@ impl SubscriptionManager { // or a newer reservation superseded it while the handler resolved. { let mut active = self.active.lock().unwrap(); - match active.get(&request_id) { + let request_sender = match active.get_mut(&request_id) { Some(Slot::Pending { generation: g, cancelled, + request_sender, }) if *g == generation => { if *cancelled { active.remove(&request_id); return; } + request_sender.take() } _ => return, - } + }; active.insert( request_id.clone(), Slot::Live { @@ -229,12 +241,12 @@ impl SubscriptionManager { cancel: Box::new(move || { let _ = cancel_tx.send(()); }), + request_sender, }, ); } let active = self.active.clone(); - let request_senders = self.request_senders.clone(); let future: BoxFuture<'static, ()> = Box::pin(async move { let completed = { @@ -285,16 +297,6 @@ impl SubscriptionManager { owned }; - if removed { - let mut senders = request_senders.lock().unwrap(); - let owned = matches!( - senders.get(&request_id), - Some((sender_generation, _, _)) if *sender_generation == generation - ); - if owned { - senders.remove(&request_id); - } - } if completed && removed { transport.send(ProtocolMessage { request_id, @@ -339,19 +341,20 @@ impl SubscriptionManager { } None => {} } - drop(active); - self.request_senders.lock().unwrap().remove(request_id); } /// Deliver one product-to-host value to an active paired subscription. pub fn handle_request(&self, request_id: &str, receive_id: u8, value: Vec) { - let sender = self - .request_senders - .lock() - .unwrap() - .get(request_id) - .filter(|(_, expected_receive_id, _)| *expected_receive_id == receive_id) - .map(|(_, _, sender)| sender.clone()); + let active = self.active.lock().unwrap(); + let request_sender = match active.get(request_id) { + Some(Slot::Pending { request_sender, .. }) + | Some(Slot::Live { request_sender, .. }) => request_sender.as_ref(), + None => None, + }; + let sender = request_sender + .filter(|request| request.receive_id == receive_id) + .map(|request| request.sender.clone()); + drop(active); if let Some(sender) = sender { let _ = sender.unbounded_send(value); } @@ -374,18 +377,6 @@ impl SubscriptionManager { for cancel in cancellations { cancel(); } - self.request_senders.lock().unwrap().clear(); - } - - fn remove_request_sender(&self, request_id: &str, generation: u64) { - let mut senders = self.request_senders.lock().unwrap(); - let owned = matches!( - senders.get(request_id), - Some((sender_generation, _, _)) if *sender_generation == generation - ); - if owned { - senders.remove(request_id); - } } } diff --git a/scripts/launch-ios-chat-playground.mjs b/scripts/launch-ios-chat-playground.mjs index 0249e27d..6b982c3a 100755 --- a/scripts/launch-ios-chat-playground.mjs +++ b/scripts/launch-ios-chat-playground.mjs @@ -2,7 +2,6 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only -import { spawnSync } from "node:child_process"; import { cpSync, existsSync, @@ -18,6 +17,13 @@ import { decodeTextMessage, labelChatDiagnosisReport, } from "./lib/chat-diagnosis-report.mjs"; +import { + bootAndInstallApp, + capture, + delay, + isLoopback, + run, +} from "./lib/ios-simulator.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const bundle = @@ -84,15 +90,7 @@ if (!existsSync(worker)) { throw new Error(`Chat product worker not found after build: ${worker}`); } -const device = selectSimulator(); -run("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", device.udid], { - stdio: "ignore", -}); -if (device.state !== "Booted") { - run("xcrun", ["simctl", "boot", device.udid]); -} -run("xcrun", ["simctl", "bootstatus", device.udid, "-b"]); -run("xcrun", ["simctl", "install", device.udid, app]); +const device = bootAndInstallApp(app); const appData = capture("xcrun", [ "simctl", @@ -221,7 +219,7 @@ console.log( async function startProductServer(urlString, root) { const url = new URL(urlString); - if (!["127.0.0.1", "localhost", "::1", "[::1]"].includes(url.hostname)) { + if (!isLoopback(url)) { throw new Error( `Product URL must be loopback for this E2E test: ${urlString}`, ); @@ -345,58 +343,6 @@ async function waitForTextPrefix(database, identifier, afterMessageId, prefix) { ); } -function delay(milliseconds) { - return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); -} - function sqlString(value) { return `'${value.replaceAll("'", "''")}'`; } - -function selectSimulator() { - const requested = - process.env.TRUAPI_IOS_E2E_DEVICE ?? process.env.IOS_SIMULATOR_DEVICE; - const simulatorList = JSON.parse( - capture("xcrun", ["simctl", "list", "devices", "available", "-j"]), - ); - const devices = Object.values(simulatorList.devices) - .flat() - .filter( - (candidate) => - candidate.isAvailable && candidate.name.startsWith("iPhone"), - ); - const selected = requested - ? devices.find( - (candidate) => - candidate.udid === requested || candidate.name === requested, - ) - : (devices.find((candidate) => candidate.state === "Booted") ?? devices[0]); - - if (!selected) { - throw new Error( - requested - ? `Requested iPhone simulator is unavailable: ${requested}` - : "No available iPhone simulator found", - ); - } - return selected; -} - -function capture(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }); - if (result.status !== 0) { - throw new Error( - `${command} ${args.join(" ")} failed with ${result.status}`, - ); - } - return result.stdout; -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { stdio: "inherit", ...options }); - if (result.status !== 0) { - throw new Error( - `${command} ${args.join(" ")} failed with ${result.status}`, - ); - } -} diff --git a/scripts/launch-ios-playground-with-signer-bot.mjs b/scripts/launch-ios-playground-with-signer-bot.mjs index 8070e7ce..31c8d9f8 100644 --- a/scripts/launch-ios-playground-with-signer-bot.mjs +++ b/scripts/launch-ios-playground-with-signer-bot.mjs @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { run } from "./lib/ios-simulator.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); loadDotEnv(resolve(repoRoot, ".env")); @@ -190,10 +191,3 @@ function nonEmpty(value) { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { stdio: "inherit", ...options }); - if (result.status !== 0) { - throw new Error(`${command} ${args.join(" ")} failed with ${result.status}`); - } -} diff --git a/scripts/launch-ios-playground.mjs b/scripts/launch-ios-playground.mjs index 01ff850d..bab0a843 100644 --- a/scripts/launch-ios-playground.mjs +++ b/scripts/launch-ios-playground.mjs @@ -2,9 +2,16 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; +import { + bootAndInstallApp, + captureOptional, + delay, + isLoopback, + run, +} from "./lib/ios-simulator.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const bundle = @@ -25,16 +32,7 @@ if (!existsSync(app)) { } const playgroundProcess = await ensurePlayground(); -const device = selectSimulator(); - -run("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", device.udid], { - stdio: "ignore", -}); -if (device.state !== "Booted") { - run("xcrun", ["simctl", "boot", device.udid]); -} -run("xcrun", ["simctl", "bootstatus", device.udid, "-b"]); -run("xcrun", ["simctl", "install", device.udid, app]); +const device = bootAndInstallApp(app); const signingHostSession = readSigningHostSession(device.udid); run( "xcrun", @@ -126,56 +124,11 @@ async function waitForPlayground(child) { if ((await probePlayground()) === "ready") { return; } - await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + await delay(500); } throw new Error(`Timed out waiting for TrUAPI playground at ${productUrl}`); } -function selectSimulator() { - const requested = - process.env.TRUAPI_IOS_E2E_DEVICE ?? process.env.IOS_SIMULATOR_DEVICE; - const simulatorList = JSON.parse( - capture("xcrun", ["simctl", "list", "devices", "available", "-j"]), - ); - const devices = Object.values(simulatorList.devices) - .flat() - .filter((device) => device.isAvailable && device.name.startsWith("iPhone")); - - const selected = requested - ? devices.find( - (device) => device.udid === requested || device.name === requested, - ) - : (devices.find((device) => device.state === "Booted") ?? devices[0]); - - if (!selected) { - throw new Error( - requested - ? `Requested iPhone simulator is unavailable: ${requested}` - : "No available iPhone simulator found", - ); - } - return selected; -} - -function isLoopback(url) { - return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); -} - -function capture(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }); - if (result.status !== 0) { - throw new Error( - `${command} ${args.join(" ")} failed with ${result.status}`, - ); - } - return result.stdout; -} - -function captureOptional(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }); - return result.status === 0 ? result.stdout.trim() : undefined; -} - function readSigningHostSession(deviceId) { const installedSession = readSessionForBundle(deviceId, bundle); if (installedSession.username && installedSession.entropyId) { @@ -253,15 +206,6 @@ function readPlistValue(plist, key) { ]); } -function run(command, args, options = {}) { - const result = spawnSync(command, args, { stdio: "inherit", ...options }); - if (result.status !== 0) { - throw new Error( - `${command} ${args.join(" ")} failed with ${result.status}`, - ); - } -} - async function keepPlaygroundAlive(child) { await new Promise((resolveWait, reject) => { const stop = () => child.kill("SIGTERM"); diff --git a/scripts/lib/ios-simulator.mjs b/scripts/lib/ios-simulator.mjs new file mode 100644 index 00000000..e23d4242 --- /dev/null +++ b/scripts/lib/ios-simulator.mjs @@ -0,0 +1,82 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawnSync } from "node:child_process"; + +export function capture(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${result.status}`, + ); + } + return result.stdout; +} + +export function captureOptional(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +export function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${result.status}`, + ); + } +} + +export function selectSimulator() { + const requested = + process.env.TRUAPI_IOS_E2E_DEVICE ?? process.env.IOS_SIMULATOR_DEVICE; + const simulatorList = JSON.parse( + capture("xcrun", ["simctl", "list", "devices", "available", "-j"]), + ); + const devices = Object.values(simulatorList.devices) + .flat() + .filter( + (candidate) => + candidate.isAvailable && candidate.name.startsWith("iPhone"), + ); + const selected = requested + ? devices.find( + (candidate) => + candidate.udid === requested || candidate.name === requested, + ) + : (devices.find((candidate) => candidate.state === "Booted") ?? devices[0]); + + if (!selected) { + throw new Error( + requested + ? `Requested iPhone simulator is unavailable: ${requested}` + : "No available iPhone simulator found", + ); + } + return selected; +} + +export function bootAndInstallApp(app) { + const device = selectSimulator(); + run( + "open", + ["-a", "Simulator", "--args", "-CurrentDeviceUDID", device.udid], + { + stdio: "ignore", + }, + ); + if (device.state !== "Booted") { + run("xcrun", ["simctl", "boot", device.udid]); + } + run("xcrun", ["simctl", "bootstatus", device.udid, "-b"]); + run("xcrun", ["simctl", "install", device.udid, app]); + return device; +} + +export function isLoopback(url) { + return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); +} + +export function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} From f9dfd217e7887c92ed4e0f83add19fd4a13bb029 Mon Sep 17 00:00:00 2001 From: pg Date: Mon, 3 Aug 2026 08:34:42 +0200 Subject: [PATCH 5/5] chore: update chat host integrations --- hosts/dotli | 2 +- hosts/ios | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hosts/dotli b/hosts/dotli index 4bb7a023..6b27c046 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 4bb7a0237f6d2dd7b6c8c9e88c8e23aba0b50659 +Subproject commit 6b27c04688558c5bc20458e6a87d31aae7eb296f diff --git a/hosts/ios b/hosts/ios index 7fe8757c..dd7708c3 160000 --- a/hosts/ios +++ b/hosts/ios @@ -1 +1 @@ -Subproject commit 7fe8757c1dc759f559365351d0a53daa9e0e1404 +Subproject commit dd7708c35a32c0ba0275433d60cd5b8dd0962a57