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/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/.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/.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/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 88cd1a2e..f2f7a3d1 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,10 @@ # 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 +NIGHTLY_TOOLCHAIN ?= nightly-2026-01-10 TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground JS_PACKAGES := js/packages @@ -41,6 +42,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 @@ -63,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/. @@ -103,6 +105,77 @@ 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 ## 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)" \ + 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_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" \ + 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. @@ -136,7 +209,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/README.md b/README.md index b215ee1d..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 @@ -139,6 +139,63 @@ 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 open its native Chat application: + +```bash +make ios-chat-run +``` + +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 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_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 +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..b8e0e004 --- /dev/null +++ b/docs/design/chat-modality-product-sdk.md @@ -0,0 +1,138 @@ +--- +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_channel + -> 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) diff --git a/docs/design/chat-modality-shared-core.md b/docs/design/chat-modality-shared-core.md new file mode 100644 index 00000000..db0bb417 --- /dev/null +++ b/docs/design/chat-modality-shared-core.md @@ -0,0 +1,320 @@ +--- +title: "Chat Modality on the Shared Rust Core" +type: design +status: accepted +created: 2026-07-30 +--- + +# Chat Modality on the Shared Rust Core + +## Summary + +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. + +Compared with the current mobile architecture, the shared-core integration +changes three things: + +- 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. + +Execution kinds beyond Chat are outside this design. + +## Legacy mobile chat bridges + +`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: + +- **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. + +These platform-specific bridges sit outside TrUAPI. + +## Architecture + +The architecture has three structural rules: + +- 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 ++---------------+---------------+ +---------------+---------------+ +| 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 | + +---------------------------------------------------------+ +``` + +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 + +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 of executable attached to this connection by the host. + pub execution_kind: ProductExecutionKind, +} + +/// Trusted kind of product executable attached to a TrUAPI 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 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 channels +} +``` + +`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. + +## Custom rendering + +`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 a product-initiated channel. +async fn custom_message_render_channel( + &self, + _cx: &CallContext, + requests: Subscription, +) -> Subscription; + +/// Values sent from the product to Rust on the renderer request stream. +pub enum ProductChatCustomMessageRenderChannelRequest { + /// 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 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. + pub message_type: String, + + /// Stored product-defined message payload. + pub payload: Vec, +} +``` + +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 class ChatClient { + customMessageRenderChannel( + requests: ObservableSource, + ): ObservableLike; +} +``` + +```ts +import { Subject } from "rxjs"; + +const requests = new Subject(); + +truapi.chat.customMessageRenderChannel(requests).subscribe({ + next(item) { + const node = render(item.messageType, item.payload); + + requests.next({ + tag: "Update", + value: { messageId: item.messageId, node }, + }); + }, +}); +``` + +```text +Native platform Shared Rust HostRuntime Product worker +(Chat UI) (ProductRuntime) (worker/index.js) + | | | + | | open renderer channel | + | |<------------------------------------| + | | renderer channel is active | + | | | + | native cell encounters stored | | + | Custom(message_id, message_type, | | + | payload) and needs its UI | | + | 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 | | + |<-------------------------------------| | + | | | +``` + +`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 + +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 ProductRuntimeControl { + 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` 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 through the optional `ChatPlatform` adapter: + +```rust +pub trait ChatPlatform: Send + Sync { + async fn create_room( + &self, + product: &ProductContext, + request: HostChatCreateRoomRequest, + ) -> Result; + + async fn post_message( + &self, + product: &ProductContext, + request: HostChatPostMessageRequest, + ) -> Result; + + fn subscribe_rooms( + &self, + product: &ProductContext, + ) -> BoxStream<'static, HostChatListSubscribeItem>; +} +``` + +## Failure behavior + +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 | Standard authentication failure | +| Native action targets a closed execution | `ProductRuntimeError::Closed` | +| Renderer is requested on a non-Chat connection | `ProductRuntimeError::Denied` | +| 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-channel value cannot be decoded | The channel and active render instances fail | +| Product disconnects during rendering | Its channel and native render streams terminate | + +## References + +- [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) +- [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/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/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/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/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..6b27c046 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 7921ce413a4a1661b36c3f5032d91287a8f160bf +Subproject commit 6b27c04688558c5bc20458e6a87d31aae7eb296f diff --git a/hosts/ios b/hosts/ios index f11bd551..dd7708c3 160000 --- a/hosts/ios +++ b/hosts/ios @@ -1 +1 @@ -Subproject commit f11bd551cf20a5d39a81a61751cf04bd194cdffc +Subproject commit dd7708c35a32c0ba0275433d60cd5b8dd0962a57 diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index f25295d0..bbac30e9 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,136 @@ 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 { + try customRendererStream { observer in + try inner.renderCustomMessage( + messageId: messageId, + messageType: messageType, + payload: payload, + observer: observer + ) + } + } + + 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 +836,27 @@ 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 { + try customRendererStream { observer in + try inner.renderCustomMessage( + messageId: messageId, + messageType: messageType, + payload: payload, + observer: observer + ) + } + } + /// 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,23 +918,72 @@ 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 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 + + init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation + } + + func onUpdate(node: NativeCustomRendererNode) { + continuation.yield(node) + } + + func onComplete() { + continuation.finish() + } } -private final class WeakTrUAPIHostCore { - weak var value: TrUAPIHostCore? +private final class WeakReference { + weak var value: Value? - init(_ value: TrUAPIHostCore) { + init(_ value: Value) { self.value = value } } private enum LiveSessionStoreForwarder { private static let lock = NSLock() - private static var cores: [ObjectIdentifier: WeakTrUAPIHostCore] = [:] + private static var cores: [ObjectIdentifier: WeakReference] = [:] + private static var runtimes: [ObjectIdentifier: WeakReference] = [:] + + static func register(_ runtime: TrUAPIHostRuntime) { + lock.lock() + runtimes[ObjectIdentifier(runtime)] = WeakReference(runtime) + lock.unlock() + } + + static func unregister(_ runtime: TrUAPIHostRuntime) { + lock.lock() + runtimes.removeValue(forKey: ObjectIdentifier(runtime)) + lock.unlock() + } static func register(_ core: TrUAPIHostCore) { lock.lock() - cores[ObjectIdentifier(core)] = WeakTrUAPIHostCore(core) + cores[ObjectIdentifier(core)] = WeakReference(core) lock.unlock() } @@ -628,14 +995,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..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[] = []; @@ -96,7 +117,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 +180,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 +264,155 @@ 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.ProductChatCustomMessageRenderChannelItem[] = []; + const requests = requestSource(); + const responses = client.chat.customMessageRenderChannel(requests); + + const subscription = responses.subscribe({ next: (item) => renders.push(item) }); + 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", + ); + 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_CHANNEL.receive, + value: T.VersionedProductChatCustomMessageRenderChannelItem.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; + requests.next(update); + const request = unwrap( + encodeWireMessage({ + requestId: subscription.subscriptionId, + payload: { + id: W.CHAT_CUSTOM_MESSAGE_RENDER_CHANNEL.receive, + value: T.VersionedProductChatCustomMessageRenderChannelRequest.enc({ + tag: "V1", + value: update, + }), + }, + }), + "encode renderer update", + ); + expect(toHex(fixture.sent[1])).toBe(toHex(request)); + }); + + it("stops forwarding after close and refuses a second subscription", () => { + const fixture = providerFixture(); + const transport = createTransport(fixture.provider); + 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_CHANNEL.interrupt, + value: _void.enc(undefined), + }, + }), + "encode renderer completion", + ), + ); + + 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", () => { 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/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 926ec77b..873f8281 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -105,6 +105,18 @@ export interface ObservableLike { [Symbol.observable](): ObservableLike; } +/** + * 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 ObservableSource { + /** + * Start consuming the source until the returned handle unsubscribes. + **/ + subscribe(observer: Partial>): { unsubscribe(): void }; +} + /** * Numeric frame ids for a one-shot request method. **/ @@ -196,6 +208,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 +243,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/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 5365f21c..27f9f457 100644 --- a/playground/README.md +++ b/playground/README.md @@ -1,19 +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 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 @@ -30,6 +35,15 @@ 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`. + +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: @@ -51,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): @@ -69,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 92c622de..20d7628c 100644 --- a/playground/package.json +++ b/playground/package.json @@ -8,14 +8,15 @@ "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", "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" @@ -42,7 +43,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/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/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/diagnosis.ts b/playground/worker/diagnosis.ts new file mode 100644 index 00000000..07627f9a --- /dev/null +++ b/playground/worker/diagnosis.ts @@ -0,0 +1,169 @@ +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]; + +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 = { + 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 { + this.#results.set(id, { id, status: "fail", details: errorDetails(error) }); + this.#onChange(); + } + + failPending(error: unknown): void { + 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 }); + } + } + 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: CHAT_DIAGNOSIS_REFRESH_ACTION, + }, + children: [], + }, + }, + { + tag: "Button", + value: { + modifiers: [], + props: { + text: this.#copyStatus === "copied" ? "Copied ✓" : "Copy report", + variant: "Secondary", + enabled: true, + loading: false, + clickAction: CHAT_DIAGNOSIS_COPY_ACTION, + }, + children: [], + }, + }, + ...(this.#copyStatus === "unavailable" + ? [ + text( + "Clipboard unavailable; long-press the report message below to copy it.", + "BodySmallRegular", + ), + ] + : []), + ]); + } +} + +function errorDetails(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +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 new file mode 100644 index 00000000..15d1b5c6 --- /dev/null +++ b/playground/worker/index.ts @@ -0,0 +1,308 @@ +import { getClientSync } from "@parity/truapi/sandbox"; +import { bytesToHex, hexToBytes } from "@parity/truapi/scale"; +import type { + CustomRendererNode, + HostChatActionSubscribeItem, + HostChatListSubscribeItem, + ObservableLike, + ProductChatCustomMessageRenderChannelItem, + ProductChatCustomMessageRenderChannelRequest, +} from "@parity/truapi"; +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 runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const diagnosticRoomId = `${ROOM_ID}-diagnosis-${runId}`; +const renderPayload = bytesToHex( + new TextEncoder().encode(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 diagnosis = new ChatDiagnosis(() => { + renderActiveMessages(); + void publishFinalReportIfComplete(); +}); + +const renderRequests = + new Subject(); +chat.customMessageRenderChannel(renderRequests).subscribe({ + next: handleRenderRequest, + error(error) { + diagnosis.fail("Chat/custom_message_render_channel", error); + }, +}); + +chat.actionSubscribe().subscribe({ + next(action) { + void handleAction(action).catch((error: unknown) => { + diagnosis.fail("Chat/action_subscribe", error); + }); + }, + error(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: ProductChatCustomMessageRenderChannelItem, +): void { + if (item.messageType !== RENDER_MESSAGE_TYPE) { + rejectRender(item.messageId); + return; + } + try { + const payload = JSON.parse( + new TextDecoder().decode(hexToBytes(item.payload)), + ) as { + version?: number; + runId?: string; + }; + + // 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) { + rejectRender(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}`, + ); + } + + activeRenderMessageIds.add(item.messageId); + 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); + rejectRender(item.messageId); + } +} + +function renderActiveMessages(): void { + const node = diagnosis.rendererNode(); + for (const messageId of activeRenderMessageIds) { + 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 === CHAT_DIAGNOSIS_REFRESH_ACTION) { + renderActiveMessages(); + } else if (trigger.actionId === CHAT_DIAGNOSIS_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(); + const result = await chat.postMessage({ + roomId: action.roomId, + payload: { + tag: "Text", + value: { + text: body ? `Echo: ${body}` : `Usage: ${ECHO_COMMAND} `, + }, + }, + }); + if (result.isErr()) { + 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() }, + }); + } +} + +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 firstValueFrom( + from(observable).pipe( + filter((item) => + item.rooms.some((candidate) => candidate.roomId === roomId), + ), + timeout({ first: 10_000 }), + ), + ); +} 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/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/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..86b1ddc6 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). @@ -268,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) @@ -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 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. @@ -775,7 +810,24 @@ pub fn clean_docs(docs: Option<&str>) -> 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 +1502,44 @@ 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 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 019c33f7..4e9dbdd1 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, 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, 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; @@ -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, " @@ -1067,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)); @@ -1114,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 ?? ""; @@ -1126,6 +1149,7 @@ fn write_observable_helper(out: &mut String) { unsubscribe: () => {{ if (closed) return; closed = true; + stopForwarding(); raw?.unsubscribe(); }}, }}; @@ -1142,6 +1166,59 @@ fn write_observable_helper(out: &mut String) { .unwrap(); } +fn write_stream_pair_helper(out: &mut String) { + out.push_str(indoc! { + r#" + function createStreamChannel({ + transport, + ids, + payload, + requests, + encodeRequest, + decodeItem, + }: { + transport: TrUApiTransport; + ids: SubscriptionFrameIds; + payload: Uint8Array; + requests: ObservableSource; + encodeRequest: (request: Request) => Uint8Array; + decodeItem: (payload: Uint8Array) => Item; + }): ObservableLike { + let used = false; + 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; + return source.subscribe(observer); + }, + [OBSERVABLE_INTEROP as typeof Symbol.observable]() { + return items; + }, + }; + return items; + } + + "# + }); +} + fn included_methods<'a>( trait_def: &'a TraitDef, wrappers: &HashMap, @@ -1414,7 +1491,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 +1587,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 +1623,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: 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, + ) + .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 +2632,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 +3011,37 @@ mod tests { assert!(!source.contains("futureCall(")); } + #[test] + 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_channel", + 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: ObservableSource")); + assert!(source.contains("): ObservableLike")); + assert!( + source.contains("return createStreamChannel") + ); + 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..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)?; @@ -69,7 +77,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..91d78569 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,29 +749,31 @@ 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_CHANNEL, 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; - Ok(subscription_stream::(stream)) + if !execution_allowed { return Err(Vec::new()); } + 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 a2664097..fd2355d6 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,35 @@ export interface ChainProvider { connect(genesisHash: Uint8Array): Promise; } +/** + * Host-implemented adapter through which product Chat calls reach native + * storage and UI. + */ +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/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-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 50465b2b..3623e860 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -1,15 +1,20 @@ //! 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-2026-01-10".to_string()) +} + fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { let marker = format!("export const {const_name} = ["); let start = src @@ -45,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. @@ -58,19 +63,20 @@ 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{}", + "`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), @@ -99,7 +105,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-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-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..60a86868 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,31 @@ pub trait PreimageHost: Send + Sync { ) -> BoxStream<'static, Result>, GenericError>>; } +/// 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. + 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..a0f4652d 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, @@ -44,7 +58,12 @@ pub struct RequestEntry { /// A registered subscription handler plus the discriminants its frames carry. pub struct SubscriptionEntry { ids: SubscriptionFrameIds, - handler: SubscriptionHandler, + handler: SubscriptionHandlerKind, +} + +enum SubscriptionHandlerKind { + OneWay(SubscriptionHandler), + Paired(StreamPairHandler), } /// Routes incoming protocol messages to registered handlers, keyed on the @@ -52,21 +71,69 @@ pub struct SubscriptionEntry { pub struct Dispatcher { by_request: HashMap, by_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(), + 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_start.insert( + ids.start_id, + SubscriptionEntry { + ids, + handler: SubscriptionHandlerKind::Paired(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 @@ -106,7 +173,7 @@ impl Dispatcher { ids.start_id, SubscriptionEntry { ids, - handler: Arc::new(handler), + handler: SubscriptionHandlerKind::OneWay(Arc::new(handler)), }, ) } @@ -134,9 +201,23 @@ impl Dispatcher { // 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, @@ -157,6 +238,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 +257,7 @@ impl Dispatcher { #[cfg(test)] mod tests { use super::*; + use futures::StreamExt; use std::sync::Mutex; fn test_spawner() -> Spawner { @@ -275,4 +360,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..eeafa089 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,21 +923,24 @@ where ); } { + 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_CHANNEL, + 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::ProductChatCustomMessageRenderChannelRequest, + >(requests); + 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 2e684951..d7f9047b 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 { @@ -78,11 +91,40 @@ 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(), + ); + Self::from_services(services, config, spawner) + } + + /// 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, + 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 { @@ -243,11 +285,36 @@ impl SigningHostRuntime { { let platform: Arc = platform; let services = RuntimeServices::new( - platform.clone(), + platform, config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner, ); + Self::from_services(services) + } + + /// 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, + 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, @@ -270,12 +337,53 @@ 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() + } + /// Disconnect the active account-authority session. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_session"))] pub async fn disconnect_session(&self) { @@ -345,6 +453,32 @@ impl HostAdmin { authority.clone(), product, )); + Self::from_product_runtime(authority, product_runtime) + } + + /// 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::from_product_runtime(authority, product_runtime) + } + + fn from_product_runtime( + authority: Arc, + product_runtime: Arc, + ) -> Self { Self { authority, product_runtime, @@ -436,6 +570,42 @@ pub struct ProductRuntime { next_dispatch_id: AtomicU64, } +/// Host-facing control handle for pushing native events into one concrete +/// product connection. +#[derive(Clone)] +pub struct ProductRuntimeControl { + runtime: Arc, + disposed: Arc, +} + +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> { + 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> { + 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. @@ -461,13 +631,42 @@ impl ProductRuntime { authority: Arc, product: ProductContext, sink: Arc, + ) -> Self { + let admin = HostAdmin::new(services.clone(), authority.clone(), product); + Self::from_admin(services, authority, admin, sink) + } + + /// 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 admin = HostAdmin::new_with_platform( + services.clone(), + authority.clone(), + product, + 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(), }); - let admin = HostAdmin::new(services.clone(), authority.clone(), product); Self { core: TrUApiCore::from_product_runtime( admin.product_runtime.clone(), @@ -518,6 +717,14 @@ impl ProductRuntime { Ok(()) } + /// 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(), + } + } + /// 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 +781,7 @@ impl ProductRuntime { { handle.abort(); } + self.admin.product_runtime.close_chat(); self.core.cancel_subscriptions(); } } @@ -625,6 +833,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 +858,143 @@ 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.control().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 + .control() + .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_channel") + .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..3306246b 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,56 @@ 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, +} + +#[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)] @@ -189,6 +232,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 +421,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,14 +445,51 @@ 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 NativeResolvedRuntimeConfig { +struct NativeResolvedHostRuntimeConfig { signing: SigningHostConfig, - product: ProductContext, local_session_secret: Option>, local_session_lite_username: Option, } +#[derive(Debug)] +struct NativeResolvedRuntimeConfig { + host: NativeResolvedHostRuntimeConfig, + product: ProductContext, +} + /// Native runtime config validation error. #[derive(Debug, Clone, thiserror::Error, uniffi::Error)] pub enum NativeRuntimeConfigError { @@ -312,6 +547,45 @@ impl TryFrom for NativeResolvedRuntimeConfig { type Error = NativeRuntimeConfigError; fn try_from(config: NativeRuntimeConfig) -> Result { + let NativeRuntimeConfig { + product_id, + execution_kind, + 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, + 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 }) + } +} + +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 { @@ -324,8 +598,6 @@ impl TryFrom for NativeResolvedRuntimeConfig { actual: config.bulletin_chain_genesis_hash.len() as u64, } })?; - let product = - ProductContext::new(config.product_id).map_err(NativeRuntimeConfigError::from)?; let signing = SigningHostConfig::new( HostInfo { name: config.host_name, @@ -341,13 +613,21 @@ impl TryFrom for NativeResolvedRuntimeConfig { )?; Ok(Self { signing, - product, 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 +754,443 @@ 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()) + } } -/// UniFFI object exposing the TrUAPI core to native hosts. +/// Process-owned native TrUAPI runtime shared by all executable connections. #[derive(uniffi::Object)] -pub struct NativeTrUApiCore { +pub struct NativeTrUApiHostRuntime { + runtime: Arc, + events: Arc, + #[cfg(feature = "ws-bridge")] + spawner: Spawner, + chat_executions: Mutex>>, +} + +impl NativeTrUApiHostRuntime { + fn from_resolved( + callbacks: Arc, + runtime_config: NativeResolvedHostRuntimeConfig, + log_marker: &str, + log_detail: &str, + ) -> Result, NativeRuntimeConfigError> { + crate::logging::init(); + 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(), + 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, + chat_executions: Mutex::new(HashMap::new()), + })) + } + + fn open_product_execution_with_callbacks( + &self, + callbacks: Arc, + product: ProductContext, + ) -> Arc { + 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_some(concrete_platform 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(); + } + } + + 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. + 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>, +} + +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")] - bridge: std::sync::Mutex>, + 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. + pub fn permission_authorization_status( + &self, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = + futures::executor::block_on(self.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)?; + futures::executor::block_on( + self.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> { + self.require_chat()?; + + #[cfg(feature = "ws-bridge")] + { + let action: v01::HostChatActionSubscribeItem = action.into(); + self.product_control + .lock() + .expect("native product control mutex poisoned") + .publish_chat_action(action) + } + #[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> { + self.require_chat()?; + #[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")] + self.stop_bridge(); + } +} + +#[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(); + product_control + .lock() + .expect("native product control mutex poisoned") + .attach(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) { + self.stop_bridge(); + } +} + +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 { + host: Arc, + execution: Arc, } #[uniffi::export] @@ -511,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 @@ -521,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. @@ -533,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. @@ -546,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 @@ -563,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 @@ -581,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`. @@ -598,17 +1290,39 @@ 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.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> { + self.execution.publish_chat_action(action) + } + + /// 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> { + self.execution + .render_custom_message(message_id, message_type, payload, observer) } } @@ -633,42 +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 = Arc::new(SigningHostRuntime::new( - platform, - runtime_config.signing, - spawner, - )); - - 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")] - callbacks, - #[cfg(feature = "ws-bridge")] - bridge: std::sync::Mutex::new(None), - })) + 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")] @@ -677,29 +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 runtime_factory = Arc::new(move |sink| runtime.product_runtime(product.clone(), sink)); - 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.execution.stop_ws_bridge(); } } @@ -763,6 +1433,7 @@ struct NativeEventBus { theme_changes: Mutex>>>, preimage_changes: Mutex>, chain_responses: Mutex>>, + chat_room_changes: Mutex>>, } struct PreimageSubscription { @@ -843,6 +1514,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 +1819,85 @@ impl PreimageHost for CallbackPlatform { } } +#[async_trait] +impl truapi_platform::ChatPlatform for CallbackPlatform { + async fn create_room( + &self, + _product: &ProductContext, + request: v01::HostChatCreateRoomRequest, + ) -> Result { + let status = self + .callbacks + .chat_create_room(request.room_id, request.name, request.icon) + .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( + &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 +1905,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 +1921,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 +1934,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 +2027,55 @@ 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 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) { @@ -1257,6 +2091,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 +2105,109 @@ 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) + )); + #[cfg(feature = "ws-bridge")] + 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) + )); + assert!(!replacement.closed.load(Ordering::Acquire)); + #[cfg(feature = "ws-bridge")] + 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 +2281,127 @@ 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 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, + &product, + request.clone(), + )) + .unwrap(); + let updated_rooms = + futures::executor::block_on(rooms.next()).expect("created room replacement"); + *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!(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!( + 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..8ca80675 --- /dev/null +++ b/rust/crates/truapi-server/src/native_renderer.rs @@ -0,0 +1,750 @@ +//! 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_parts(&self) -> (&[v01::Modifier], &[v01::CustomRendererNode]) { + match &self.inner { + 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] { + self.component_parts().1 + } +} + +/// 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) { + self.cancel(); + } +} + +#[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 _ = Abortable::new( + async move { + while let Some(inner) = stream.next().await { + observer.on_update(Arc::new(NativeCustomRendererNode { inner })); + } + observer.on_complete(); + }, + registration, + ) + .await; + })); + 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..1b7b286f 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, + ProductChatCustomMessageRenderChannelItem, ProductChatCustomMessageRenderChannelRequest, +}; 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,130 @@ 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 native_chat_platform( + &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); + } + self.chat_platform + .clone() + .ok_or(crate::host_core::ProductRuntimeError::Unsupported) + } + + 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 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.native_chat_platform()?; + 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.native_chat_platform()?; + 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_channel"))] + async fn custom_message_render_channel( + &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 +1960,6 @@ impl ResourceAllocation for ProductRuntimeHost { }; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::ResourceAllocation( ResourceAllocationReview { @@ -1952,7 +2073,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 +2125,6 @@ impl Preimage for ProductRuntimeHost { ) .await?; let confirmed = self - .services .platform .confirm_user_action(UserConfirmationReview::PreimageSubmit( PreimageSubmitReview { @@ -2108,21 +2227,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 +2253,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 +2273,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..046b1739 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/chat.rs @@ -0,0 +1,411 @@ +//! 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, ProductChatCustomMessageRenderChannelItem, + ProductChatCustomMessageRenderChannelRequest, +}; +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>, +} + +#[derive(Default)] +struct State { + actions: Option>, + action_buffer: VecDeque, + renderer: Option, + next_renderer_generation: u64, + closed: bool, +} + +/// 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, + 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() { + match sender.unbounded_send(action) { + Ok(()) => return Ok(()), + Err(error) => action = error.into_inner(), + } + 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; + }; + let ProductChatCustomMessageRenderChannelRequest::V1(request) = request; + match request { + 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); + } + } + v01::ProductChatCustomMessageRenderChannelRequest::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 = ProductChatCustomMessageRenderChannelItem::V1( + v01::ProductChatCustomMessageRenderChannelItem { + 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(ProductChatCustomMessageRenderChannelItem::V1( + v01::ProductChatCustomMessageRenderChannelItem { + message_id: "one".into(), + message_type: "vote".into(), + payload: vec![1], + } + )) + ); + assert_eq!( + block_on(work.next()), + Some(ProductChatCustomMessageRenderChannelItem::V1( + v01::ProductChatCustomMessageRenderChannelItem { + message_id: "two".into(), + message_type: "balance".into(), + payload: vec![2], + } + )) + ); + + let node = CustomRendererNode::String { + text: "second".into(), + }; + requests_tx + .unbounded_send(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::Update { + message_id: "two".into(), + node: node.clone(), + }, + )) + .unwrap(); + + assert_eq!(block_on(second.next()), Some(node)); + + requests_tx + .unbounded_send(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::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(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::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(ProductChatCustomMessageRenderChannelRequest::V1( + v01::ProductChatCustomMessageRenderChannelRequest::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..c3330d61 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,39 @@ impl RuntimeServices { people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, + ) -> Arc { + Self::build( + platform, + None, + people_chain_genesis_hash, + bulletin_chain_genesis_hash, + spawner, + ) + } + + /// 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], + bulletin_chain_genesis_hash: [u8; 32], + spawner: Spawner, ) -> Arc { let chain_provider = Arc::new(HostChainProvider { platform: platform.clone(), @@ -64,6 +99,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..6e47f450 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 @@ -72,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 @@ -108,6 +142,14 @@ 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); let mut active = self.active.lock().unwrap(); if let Some(Slot::Live { cancel, .. }) = active.insert( @@ -115,6 +157,7 @@ impl SubscriptionManager { Slot::Pending { generation, cancelled: false, + request_sender, }, ) { cancel(); @@ -125,6 +168,18 @@ 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 (sender, receiver) = mpsc::unbounded(); + let token = self + .reserve_with_request_sender(request_id, Some(RequestSender { 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) { @@ -165,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 { @@ -184,6 +241,7 @@ impl SubscriptionManager { cancel: Box::new(move || { let _ = cancel_tx.send(()); }), + request_sender, }, ); } @@ -285,6 +343,23 @@ impl SubscriptionManager { } } + /// 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 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); + } + } + /// Cancel and forget every pending or live subscription owned by this /// manager. Used when the product runtime is disposed, where no further /// frames should be emitted and platform resources must be released. @@ -309,6 +384,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 +443,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..ac131a91 100644 --- a/rust/crates/truapi/src/api/chat.rs +++ b/rust/crates/truapi/src/api/chat.rs @@ -5,12 +5,13 @@ use crate::versioned::chat::{ HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, - ProductChatCustomMessageRenderSubscribeItem, ProductChatCustomMessageRenderSubscribeRequest, + ProductChatCustomMessageRenderChannelItem, ProductChatCustomMessageRenderChannelRequest, }; 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,32 +106,32 @@ 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. + /// Serves custom-message rendering over a product-initiated channel. + /// + /// 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 - /// import { firstValueFrom, from } from "rxjs"; + /// import { Subject } from "rxjs"; + /// import type { ProductChatCustomMessageRenderChannelRequest } from "@parity/truapi"; /// - /// const item = await firstValueFrom( - /// from( - /// truapi.chat.customMessageRenderSubscribe({ - /// request: { - /// messageId: "msg-1", - /// messageType: "custom-render-demo", - /// payload: "0x", - /// }, - /// }), - /// ), - /// ); - /// console.log("render request received:", item); + /// const requests = new Subject(); + /// truapi.chat.customMessageRenderChannel(requests).subscribe({ + /// next(item) { + /// requests.next({ + /// tag: "Failed", + /// value: { messageId: item.messageId }, + /// }); + /// }, + /// }); /// ``` #[wire(start_id = 52)] - async fn custom_message_render_subscribe( + async fn custom_message_render_channel( &self, _cx: &CallContext, - _request: ProductChatCustomMessageRenderSubscribeRequest, - ) -> Subscription { + _requests: Subscription, + ) -> Subscription { Subscription::empty() } } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a138df03..1f9db162 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 ProductChatCustomMessageRenderChannelRequest = + LatestOf; + /// Host-to-product custom render work item. + pub type ProductChatCustomMessageRenderChannelItem = + 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..7901d445 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 ProductChatCustomMessageRenderChannelRequest { + /// 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, + }, +} + +/// Render item sent from Rust to the product on the renderer response stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ProductChatCustomMessageRenderChannelItem { + /// Stable identifier used to correlate updates and triggered 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..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::CustomRendererNode } + 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/codegen.sh b/scripts/codegen.sh index 7339965b..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,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-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 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..6b982c3a --- /dev/null +++ b/scripts/launch-ios-chat-playground.mjs @@ -0,0 +1,348 @@ +#!/usr/bin/env node +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { + cpSync, + existsSync, + mkdirSync, + 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"; +import { + bootAndInstallApp, + capture, + delay, + isLoopback, + run, +} from "./lib/ios-simulator.mjs"; + +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 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 ?? ""; +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"); +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", +); +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}`); +} +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 = bootAndInstallApp(app); + +const appData = capture("xcrun", [ + "simctl", + "get_app_container", + device.udid, + bundle, + "data", +]).trim(); +const connectionMarkers = [ + 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, + 60_000, + "Ensure the selected simulator has completed Polkadot onboarding.", + ); + if (expectDiagnosis) { + const report = await waitForTextPrefix( + userDataDatabase, + chatIdentifier, + messageWatermark, + 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, + ); + } + 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, + diagnosisVerified: expectDiagnosis, + customRendererVerified: expectCustomRenderer, + productUrl, + verifiedExecutions: ["Chat"], + worker, + workerDestination, + report: expectDiagnosis ? reportPath : undefined, + screenshot, + verified: true, + }), +); + +async function startProductServer(urlString, root) { + const url = new URL(urlString); + if (!isLoopback(url)) { + 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(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 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, hint) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (files.every(existsSync)) { + return; + } + await delay(250); + } + throw new Error( + `Timed out waiting for files: ${files.join(", ")}${hint ? `\n${hint}` : ""}`, + ); +} + +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 waitForTextPrefix(database, identifier, afterMessageId, prefix) { + const deadline = Date.now() + 30_000; + + while (Date.now() < deadline) { + if (existsSync(database)) { + const query = ` + 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} + ORDER BY message.Z_PK; + `; + 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 a message starting with ${JSON.stringify(prefix)} in ${identifier}`, + ); +} + +function sqlString(value) { + return `'${value.replaceAll("'", "''")}'`; +} 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 new file mode 100644 index 00000000..bab0a843 --- /dev/null +++ b/scripts/launch-ios-playground.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +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 = + 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 = bootAndInstallApp(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 delay(500); + } + throw new Error(`Timed out waiting for TrUAPI playground at ${productUrl}`); +} + +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, + ]); +} + +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}`)); + } + }); + }); +} 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), + ); +}); 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)); +} 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