diff --git a/.changeset/unified-renderer.md b/.changeset/unified-renderer.md new file mode 100644 index 000000000..f3f207910 --- /dev/null +++ b/.changeset/unified-renderer.md @@ -0,0 +1,18 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +`Renderer` is the one service through which a product draws a body inside a host surface. The host starts +`renderer.onRender` with a `RenderContext` (`ChatMessage`, `InputWidget`, `PocketCard`) and an opaque payload; the +product streams `RendererNode` trees, and a press inside a tree reaches `renderer.actionSubscribe` as +`{ context, actionId, payload }`. `Chat` has no `custom_message_render`; a `Custom` chat message renders through +`Renderer` with a `ChatMessage` context, and `ChatActionPayload.ActionTriggered` carries only host-drawn `Actions` +button presses. + +`RendererNode` replaces `CustomRendererNode` with `Image` (`ImageSource`, `ImageFit`), `Effect`, `Shape.Square`, +`Modifier.Opacity` and `Modifier.BlendingMode`; `Spacer`, `TextField` and `Image` carry no `children`, and the +single-field `Modifier` and `Shape` variants are tuple variants. + +Hosts call `provider.render(request, sink)` and `provider.publishRendererAction(item)`; `publishChatAction` is the path +for posted messages, commands and host-drawn `Actions` buttons. diff --git a/CLAUDE.md b/CLAUDE.md index a08b4c59f..9b2985abc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,7 @@ When the Rust trait surface changes, rerun: ``` That will repopulate the ignored generated TS under `js/packages/truapi/src/generated/`, -`js/packages/truapi/src/playground/codegen/`, and `js/packages/truapi/test/generated/examples/`. +`js/packages/truapi/src/playground/codegen/`, and `playground/test/generated/examples/`. After regenerating, rebuild the client and refresh the playground's link copy: ```bash diff --git a/README.md b/README.md index 4bae6d81f..de9b0c989 100644 --- a/README.md +++ b/README.md @@ -275,8 +275,8 @@ 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. The -worker drives all six Chat methods, so a host without bot registration reports -that row red: +worker drives all five Chat methods and both Renderer methods, so a host +without bot registration reports that row red: ```bash make ios-chat-run diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 6718aa0d0..1e3799886 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -132,7 +132,7 @@ unverified. Contextual output escaping is the host's job. `postMessage` receives any `ChatMessageContent` variant; throw from it for one this host cannot render. The id it returns is the correlation key `ActionTrigger.messageId` carries back, so it must name that message for as long as the host stores it. -On the execution: `publishChatAction` delivers a user's action back to the product (buffered until it subscribes), `notifyChatRoomsChanged` republishes the room list, `renderCustomMessage` returns a `Flow` of typed UI for a stored custom message, and `sessionChatIdentityKey` reads the session's X25519 chat identity key. +On the execution: `publishChatAction` delivers a user's action back to the product (buffered until it subscribes), `notifyChatRoomsChanged` republishes the room list, `render` returns a `Flow` of `RendererNode` trees for one render context, `publishRendererAction` delivers a renderer action back to the product, and `sessionChatIdentityKey` reads the session's X25519 chat identity key. An open render stream is one worker reference the core holds on the product's behalf; the transition it causes arrives on the runtime bridge's `workerDemandChanged`, never on the execution's. Two rules the core cannot check are the host's to keep: send a render context only for a surface the product's manifest `includes`, and publish a renderer action only from the current tree of an open render stream. ## Architecture diff --git a/android/truapi-host/build.gradle.kts b/android/truapi-host/build.gradle.kts index b29152a99..3e8abbd99 100644 --- a/android/truapi-host/build.gradle.kts +++ b/android/truapi-host/build.gradle.kts @@ -57,8 +57,8 @@ dependencies { // UniFFI Kotlin bindings use JNA for FFI. api("net.java.dev.jna:jna:5.14.0@aar") // UniFFI async functions and callbacks use cancellable continuations and - // jobs, and `TrUAPIProductExecution.renderCustomMessage` returns a `Flow`, - // so consumers compile against this. + // jobs, and `TrUAPIProductExecution.render` returns a `Flow`, so consumers + // compile against this. api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } 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 737c7a3cb..b94b78802 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 @@ -36,14 +36,16 @@ import uniffi.truapi.ChatBotRegistrationStatus import uniffi.truapi.ChatMessageContent import uniffi.truapi.ChatRoom import uniffi.truapi.ChatRoomRegistrationStatus -import uniffi.truapi.CustomRendererNode import uniffi.truapi.HostChatActionSubscribeItem import uniffi.truapi.HostDevicePermissionRequest import uniffi.truapi.HostFeatureSupportedRequest import uniffi.truapi.HostLocaleSubscribeItem import uniffi.truapi.HostPlatform import uniffi.truapi.HostPushNotificationRequest +import uniffi.truapi.HostRendererActionSubscribeItem +import uniffi.truapi.ProductRendererRenderRequest import uniffi.truapi.RemotePermission +import uniffi.truapi.RendererNode import uniffi.truapi.HostThemeSubscribeItem import uniffi.truapi.ThemeName import uniffi.truapi.ThemeVariant @@ -56,7 +58,7 @@ import uniffi.truapi_platform.PermissionAuthorizationStatus import uniffi.truapi_platform.UserConfirmationReview import uniffi.truapi_server.HostCallbacks import uniffi.truapi_server.NativeChatCallbacks -import uniffi.truapi_server.NativeCustomRendererObserver +import uniffi.truapi_server.NativeRendererObserver import uniffi.truapi_server.NativeDevicePermissionStatus import uniffi.truapi_server.NativeProductExecution import uniffi.truapi_server.NativeTrUApiHostRuntime @@ -870,7 +872,7 @@ class TrUAPIHostRuntime private constructor( } /** A render the product declined or could not encode. */ -class CustomRendererStreamException( +class RendererStreamException( /** Why the product ended the render. */ val reason: String, ) : Exception(reason) @@ -914,25 +916,21 @@ class TrUAPIProductExecution internal constructor( } /** - * Request typed native UI for one stored custom Chat message. The flow - * subscribes on collection, so a closed or non-Chat execution fails the + * Request a native renderer tree for one render context. The flow + * subscribes on collection, so a closed or non-Worker execution fails the * collector with [ProductRuntimeException] rather than this call. It * cancels the renderer when collection ends; * each emission is a complete replacement tree, so only the latest is kept * when the collector falls behind. */ - fun renderCustomMessage( - messageId: String, - messageType: String, - payload: ByteArray, - ): Flow = + fun render(request: ProductRendererRenderRequest): Flow = callbackFlow { val observer = - object : NativeCustomRendererObserver { + object : NativeRendererObserver { // The core declares all three infallible, so uniffi has no // error type to convert a throw into and panics -- which // aborts under `panic = "abort"`. - override fun onUpdate(node: CustomRendererNode) { + override fun onUpdate(node: RendererNode) { runCatching { trySend(node) } } @@ -943,16 +941,25 @@ class TrUAPIProductExecution internal constructor( // The last tree sent is partial, so closing with a cause // keeps this distinct from a clean end for the collector. override fun onError(reason: String) { - runCatching { close(CustomRendererStreamException(reason)) } + runCatching { close(RendererStreamException(reason)) } } } - val subscription = inner.renderCustomMessage(messageId, messageType, payload, observer) + val subscription = inner.render(request, observer) awaitClose { subscription.cancel() subscription.close() } }.conflate() + /** + * Publish one native renderer action, buffering it until the product + * connection subscribes. + */ + @Throws(ProductRuntimeException::class) + fun publishRendererAction(item: HostRendererActionSubscribeItem) { + inner.publishRendererAction(item) + } + /** Read the active session's X25519 chat identity private key, if any. */ @Throws(HostRejection::class) fun sessionChatIdentityKey(): ByteArray? = inner.sessionChatIdentityKey() diff --git a/docs/rfcs/unified-renderer.md b/docs/rfcs/unified-renderer.md new file mode 100644 index 000000000..5332dfb87 --- /dev/null +++ b/docs/rfcs/unified-renderer.md @@ -0,0 +1,457 @@ +--- +title: "Unified Renderer" +owner: "@johnthecat" +status: draft +--- + +# RFC — Unified Renderer + +## Summary + +Products draw parts of host surfaces, and the host keeps control of what reaches the screen. `Renderer` is the one +service for it. The product describes the body as a `RendererNode` tree over a closed vocabulary, the host draws the +tree from its own design system inside a frame it controls, and an action inside the tree reaches the product on one +stream. A `RenderContext` on the render request and on the action names the surface and the body. A custom chat message +is one such body, and `Chat` has no rendering pair of its own. + +## Motivation + +A product has one way to show its own information inside a host surface: a `ChatMessageContent::Custom` message, +rendered through `Chat::custom_message_render` and answered through `Chat::action_subscribe`. The request is keyed by +message, the press arrives beside posted messages and slash commands, and the press item does not say whether the button +was drawn by the host for an `Actions` message or by the product in a tree. + +The input modality needs a product to draw a candidate, and the pocket modality a card face. A render and action pair +per surface gives a product one render callback and one action stream per surface for the same tree type. + +## Requirements + +- **Surface-neutral.** A body is drawn and its actions reported the same way on every surface. +- **Closed.** A product names layouts and tokens from a fixed vocabulary, never markup, stylesheets or URLs. +- **Correlated.** An action carries enough for the product to find the body and the handler without a registry. +- **Live.** A product redraws a displayed body in place, and no stream is open for a body off screen. +- **Framed.** The host draws identity, bounds and dismissal around every body and interprets the tree itself. + +## Approach + +The design has three parts: + +- The `Renderer` service and the `RenderContext` that binds its two methods. +- The `RendererNode` tree. +- The action pipeline from a rendered node to the product. + +### Service + +`Renderer` is a worker service beside `Chat` in the canonical `truapi` crate, at `api/renderer.rs`. Its types live in +`v01::renderer` and are re-exported through `truapi::latest`. + +```rust +/// Where a product-rendered body lives, and the id that names it there. +pub enum RenderContext { + /// A message in a chat room. + ChatMessage { + /// Room the message was posted in. + room_id: String, + /// Message id, as returned by `Chat::post_message`. + message_id: String, + /// Product-defined discriminator, as stored in `ChatCustomMessage::message_type`. + message_type: String, + }, + /// A candidate answered to an input query. + InputWidget { + /// Candidate id, as the product answered it. + candidate_id: String, + }, + /// A card face in the host's Pocket collection. + PocketCard { + /// Card id, as declared in the product's worker manifest. + card_id: String, + }, +} + +/// A body the host needs drawn. +pub struct ProductRendererRenderRequest { + /// Where the body lives. + pub context: RenderContext, + /// Product-defined payload, opaque to the host. + pub payload: Vec, +} + +/// An action triggered inside a product-rendered body. +pub struct HostRendererActionSubscribeItem { + /// Where the body lives. + pub context: RenderContext, + /// Which action was triggered, as named in the renderer tree. + pub action_id: String, + /// Data the node attached to the action. Empty for a `Button` press. + pub payload: Vec, +} +``` + +```rust +/// Product-rendered bodies and the actions triggered inside them. +pub trait Renderer: Send + Sync { + /// Streams renderer trees for one product-rendered body. Each item + /// replaces the previous tree. The stream stays open while the body is + /// displayed so the product can redraw in place. + fn render( + &self, + _cx: &CallContext, + _request: ProductRendererRenderRequest, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } + + /// Subscribe to actions triggered inside this product's rendered bodies. + async fn action_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } +} +``` + +`RenderContext` has one variant per surface, holding the ids that surface uses to name a body. The host scopes an id to +the product that minted it, and for `InputWidget` also to the query the candidate answered. A render request and the +actions inside it carry the same context verbatim, so a product correlates by equality. A new surface is a new variant. + +A product registers one `render` handler, and the host calls it for every context of a surface the product's manifest +`includes`. A product that draws on more than one surface matches on `context`. Contexts for surfaces the product does +not include are never sent. + +### Render Tree + +A body is one `RendererNode`. An absent `OptionBool` leaves the default to the host. + +```rust +/// A size in logical pixels, SCALE-encoded as `Compact`. +pub type Size = Compact; + +/// Edge dimensions. `bottom` defaults to `top` and `start` to `end` when absent. +pub struct Dimensions { + pub top: Size, + pub end: Size, + pub bottom: Option, + pub start: Option, +} + +/// Typography presets, resolved by the host's design system. +pub enum TypographyStyle { + HeadlineLarge, + TitleMediumRegular, + BodyLargeRegular, + BodyMediumRegular, + BodySmallRegular, +} + +/// Button emphasis. +pub enum ButtonVariant { + Primary, + Secondary, + /// No background. + Text, +} + +/// Semantic color tokens, resolved by the host's theme. +pub enum ColorToken { + FgPrimary, + FgSecondary, + FgTertiary, + BgSurfaceMain, + BgSurfaceContainer, + BgSurfaceNested, + FgSuccess, + FgError, + FgWarning, +} + +/// Placement of content within a `Box`. +pub enum ContentAlignment { + TopStart, + TopCenter, + TopEnd, + CenterStart, + Center, + CenterEnd, + BottomStart, + BottomCenter, + BottomEnd, +} + +/// Cross-axis alignment of `Column` children. +pub enum HorizontalAlignment { + Start, + Center, + End, +} + +/// Cross-axis alignment of `Row` children. +pub enum VerticalAlignment { + Top, + Center, + Bottom, +} + +/// Main-axis distribution of children. +pub enum Arrangement { + Start, + End, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +/// Outline of a background or border. +pub enum Shape { + Rounded(Size), + Circle, + Square, +} + +pub struct BorderStyle { + pub width: Size, + pub color: ColorToken, + pub shape: Option, +} + +pub struct Background { + pub color: ColorToken, + pub shape: Option, +} + +/// How a node composites with what is behind it. The values are those common to CSS +/// `mix-blend-mode`, SwiftUI `BlendMode` and Compose `BlendMode`. +pub enum BlendingMode { + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +/// Layout and styling applied to one node. +pub enum Modifier { + /// Outer spacing. + Margin(Dimensions), + /// Inner spacing. + Padding(Dimensions), + Background(Background), + Border(BorderStyle), + Height(Size), + Width(Size), + MinWidth(Size), + MinHeight(Size), + FillWidth(bool), + FillHeight(bool), + /// 0 is transparent, 255 is opaque. + Opacity(u8), + BlendingMode(BlendingMode), +} +``` + +```rust +pub struct BoxProps { + pub content_alignment: Option, +} + +pub struct ColumnProps { + pub horizontal_alignment: Option, + pub vertical_arrangement: Option, +} + +pub struct RowProps { + pub vertical_alignment: Option, + pub horizontal_arrangement: Option, +} + +pub struct TextProps { + pub style: Option, + pub color: Option, +} + +pub struct ButtonProps { + /// Button label. + pub text: String, + pub variant: Option, + /// Whether the button accepts presses. + pub enabled: OptionBool, + /// Whether the button shows a loading state. A loading button accepts no + /// presses. + pub loading: OptionBool, + /// Action triggered on press. A button without one is inert. + pub click_action: Option, +} + +/// Where image bytes come from. The host fetches them; the tree carries no URL. +pub enum ImageSource { + /// A Bulletin chain blob, addressed by its CID. + Bulletin(String), + /// A file inside the product's executable archive, as a path relative to + /// the archive root. + Archive(String), +} + +pub enum ImageFit { + /// The image is not resized. + None, + /// Resized to fill the container without preserving the aspect ratio. + Fill, + /// Preserves the aspect ratio and fills the container, cutting overflow. + Cover, + /// Preserves the aspect ratio and fits inside the container, leaving empty space if needed. + Contain, + /// Whichever of `None` or `Contain` yields the smaller image. + ScaleDown, +} + +pub struct ImageProps { + pub source: ImageSource, + /// Defaults to `Fill`. + pub fit: Option, +} + +/// A visual effect. Each variant names one effect and carries its parameters. +pub enum Effect { + Rainbow, +} + +pub struct EffectProps { + pub effect: Effect, +} + +pub struct TextFieldProps { + /// Current value. + pub text: String, + /// Shown when the value is empty. + pub placeholder: Option, + pub label: Option, + /// Whether the field accepts input. + pub enabled: OptionBool, + /// Action triggered on every value change, carrying the new value. + pub value_change_action: Option, +} + +/// A node in a product-rendered tree. Container variants recurse through +/// `children`. +pub enum RendererNode { + /// Draws nothing. + Nil, + /// A text run. + String { text: String }, + /// Generic container. + Box { + modifiers: Vec, + props: BoxProps, + children: Vec, + }, + /// Vertical layout. + Column { + modifiers: Vec, + props: ColumnProps, + children: Vec, + }, + /// Horizontal layout. + Row { + modifiers: Vec, + props: RowProps, + children: Vec, + }, + /// Flexible space. + Spacer { + modifiers: Vec, + }, + /// Styled text. + Text { + modifiers: Vec, + props: TextProps, + children: Vec, + }, + Button { + modifiers: Vec, + props: ButtonProps, + children: Vec, + }, + /// Single-line text input. + TextField { + modifiers: Vec, + props: TextFieldProps, + }, + /// Image, sized by modifiers. + Image { + modifiers: Vec, + props: ImageProps, + }, + /// Applies its effect to its children. + Effect { + props: EffectProps, + children: Vec, + }, +} +``` + +The host draws every node from its own design system. + +An `ImageSource` is fetched from the Bulletin IPFS gateway or the product's executable archive. An image that cannot be +fetched draws as empty space. A tree nested deeper than the host's bound is a decode failure of the stream. + +### Actions + +An action id is product-chosen and opaque to the host. `Button::click_action` and `TextField::value_change_action` are +the action sites. + +Action payload by node, as the host sends it in `HostRendererActionSubscribeItem::payload`: + +| Node | Trigger | `payload` | +| ----------- | ------------ | ---------------------------------------------- | +| `Button` | Press | Empty | +| `TextField` | Value change | UTF-8 bytes of the new value, no length prefix | + +The host fills `context` from the render request whose tree the node belongs to. Only the current tree of an open +`render` stream is a source of actions. Actions published before the product subscribes are buffered until it does. + +### Streams + +The host opens a `render` stream when the body comes on screen and closes it when the body leaves; the same body coming +back opens a fresh stream with the same request. Each item replaces the whole tree. A stream that ends cleanly leaves +the last tree on screen. After an error the host shows the product's identity and no body. A product that cannot draw +the body ends the stream with an error. + +An open `render` stream is one worker reference in [Worker Lifecycle](worker-lifecycle.md) terms, whichever surface +opened it. + +### Chat + +`Chat` has no `custom_message_render`. A `Custom` message is rendered through `Renderer::render` with a `ChatMessage` +context and the stored message payload as `payload`. Actions inside its tree arrive on `Renderer::action_subscribe`. +`ChatActionPayload::ActionTriggered` carries only a press on a button the host draws for a +`ChatMessageContent::Actions` message. + +## Compatibility + +The change is breaking for a product that renders `Custom` chat messages: its render handler is `renderer.onRender`, its +tree-action handler is `renderer.actionSubscribe`, and its trees are `RendererNode`. A product that posts no `Custom` +messages is unaffected. Hosts ship `Renderer` and the `Chat` change together. + +## Trade-offs + +- Chat products that render custom messages break once. +- A string `surface` field instead of the `RenderContext` enum would move the id set into an untyped payload and lose + correlation by equality. + +## Open questions + +- The `Effect` variants beyond `Rainbow`, and the parameters each carries. +- Whether a pocket card drawn through `Renderer` needs a viewport signal beyond the stream closing. diff --git a/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockProductExecution.swift b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockProductExecution.swift index d3c79a02f..970bec5f4 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockProductExecution.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockProductExecution.swift @@ -30,14 +30,12 @@ final class MockProductExecution: TrUAPIProductExecutionProtocol, @unchecked Sen func publishChatAction(_: HostChatActionSubscribeItem) throws {} - func renderCustomMessage( - messageId _: String, - messageType _: String, - payload _: Data - ) throws -> AsyncThrowingStream { + func render(_: ProductRendererRenderRequest) throws -> AsyncThrowingStream { AsyncThrowingStream { $0.finish() } } + func publishRendererAction(_: HostRendererActionSubscribeItem) throws {} + func permissionAuthorizationStatus( request: PermissionAuthorizationRequest ) async throws -> PermissionAuthorizationStatus { diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 340c4092b..b0b747f77 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -174,9 +174,16 @@ untrusted: they may name a message in another room, or one that never existed. On the execution: `publishChatAction` delivers a user's action back to the product, buffering up to 64 before it subscribes; `notifyChatRoomsChanged` -republishes the room list; `renderCustomMessage` returns a stream of typed UI -for a stored custom message; and `sessionChatIdentityKey` reads the session's -X25519 chat identity private key, which must not be logged or persisted. +republishes the room list; `render` returns a stream of `RendererNode` trees +for one render context; `publishRendererAction` delivers a renderer action +back to the product; and `sessionChatIdentityKey` reads the session's X25519 +chat identity private key, which must not be logged or persisted. An open +render stream is one worker reference the core holds on the product's behalf; +the transition it causes arrives on the runtime bridge's +`workerDemandChanged`, never on the execution's. Two rules the core +cannot check are the host's to keep: send a render context only for a surface +the product's manifest `includes`, and publish a renderer action only from the +current tree of an open render stream. ## Architecture diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 2e1922a24..158224579 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -850,11 +850,8 @@ public protocol TrUAPIProductExecutionProtocol: AnyObject, Sendable { func stopWsBridge() func close() func publishChatAction(_ action: HostChatActionSubscribeItem) throws - func renderCustomMessage( - messageId: String, - messageType: String, - payload: Data - ) throws -> AsyncThrowingStream + func render(_ request: ProductRendererRenderRequest) throws -> AsyncThrowingStream + func publishRendererAction(_ item: HostRendererActionSubscribeItem) throws func permissionAuthorizationStatus( request: PermissionAuthorizationRequest ) async throws -> PermissionAuthorizationStatus @@ -907,21 +904,18 @@ public final class TrUAPIProductExecution: TrUAPIProductExecutionProtocol, @unch 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 render( + _ request: ProductRendererRenderRequest + ) throws -> AsyncThrowingStream { + try rendererStream { observer in + try inner.render(request: request, observer: observer) } } + public func publishRendererAction(_ item: HostRendererActionSubscribeItem) throws { + try inner.publishRendererAction(item: item) + } + public func permissionAuthorizationStatus( request: PermissionAuthorizationRequest ) async throws -> PermissionAuthorizationStatus { @@ -991,13 +985,13 @@ private func hostRejectionReason(_ error: Error) -> String { /// whole failed statement. private let hostRejectionReasonMaxCharacters = 256 -private func customRendererStream( - _ subscribe: (CustomRendererStreamObserver) throws -> NativeCustomRendererSubscription -) throws -> AsyncThrowingStream { +private func rendererStream( + _ subscribe: (RendererStreamObserver) throws -> NativeRendererSubscription +) throws -> AsyncThrowingStream { let (stream, continuation) = AsyncThrowingStream.makeStream( - of: CustomRendererNode.self + of: RendererNode.self ) - let observer = CustomRendererStreamObserver(continuation: continuation) + let observer = RendererStreamObserver(continuation: continuation) let subscription = try subscribe(observer) continuation.onTermination = { @Sendable _ in subscription.cancel() @@ -1005,14 +999,14 @@ private func customRendererStream( return stream } -private final class CustomRendererStreamObserver: NativeCustomRendererObserver, @unchecked Sendable { - private let continuation: AsyncThrowingStream.Continuation +private final class RendererStreamObserver: NativeRendererObserver, @unchecked Sendable { + private let continuation: AsyncThrowingStream.Continuation - init(continuation: AsyncThrowingStream.Continuation) { + init(continuation: AsyncThrowingStream.Continuation) { self.continuation = continuation } - func onUpdate(node: CustomRendererNode) { + func onUpdate(node: RendererNode) { continuation.yield(node) } @@ -1023,12 +1017,12 @@ private final class CustomRendererStreamObserver: NativeCustomRendererObserver, /// The product could not serve the render, so the last tree yielded is /// partial. Finishing with an error keeps that distinct from a clean end. func onError(reason: String) { - continuation.finish(throwing: CustomRendererStreamError(reason: reason)) + continuation.finish(throwing: RendererStreamError(reason: reason)) } } /// A render the product declined or could not encode. -public struct CustomRendererStreamError: Error, CustomStringConvertible { +public struct RendererStreamError: Error, CustomStringConvertible { /// Why the product ended the render. public let reason: String diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index dc26e6936..927214d41 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -113,15 +113,24 @@ Under `createWebWorkerPairingHostRuntime` the presence of each optional group is reported to the worker in its `init` message, so the core sees the same capability set on both sides of the boundary. -### Custom chat messages +### Product-rendered bodies -A host that serves `chat` can also draw product-authored custom messages and -send back what the user does with them. Both live on the product provider and -are present only on runtimes holding a live channel to the core: +A host can ask a product to draw one body — a chat message, an input-widget +candidate, a Pocket card — and send back what the user does with it. Both entry +points live on the product provider and are present only on runtimes holding a +live channel to the core: ```ts -const stop = provider.renderCustomMessage!( - { messageId, messageType, payload }, +import type { RenderContext } from "@parity/truapi"; + +// `payload` here is the product-defined body, hex-encoded. +const context: RenderContext = { + tag: "ChatMessage", + value: { roomId, messageId, messageType }, +}; + +const stop = provider.render!( + { context, payload }, { onUpdate: (node) => setTree(node), // complete replacement tree each time onComplete: () => setTree(null), @@ -130,23 +139,35 @@ const stop = provider.renderCustomMessage!( ); // A button inside the rendered tree was tapped: -await provider.publishChatAction!({ - roomId, - peer: productId, - payload: { tag: "ActionTriggered", value: { messageId, actionId, payload } }, +await provider.publishRendererAction!({ + context, + actionId, + payload: "0x", // a `Button` press carries no data }); stop(); // stop rendering; safe to call more than once ``` -`renderCustomMessage` reports failure through `onError` rather than throwing, so -one dead render cannot take the surrounding message list with it. Exactly one -terminal fires per render: `onComplete` means the last tree delivered stands, -`onError` means it is partial and must not be shown as final. A product that -declines the render, a tree that fails to decode, a closed connection, and a -throwing renderer all arrive as `onError`. Both entry points sit behind the same -access policy as every other Chat call: a connection that is not a `Worker` -execution with a live session is refused. +A `TextField` value change instead carries the UTF-8 bytes of the new value, +with no length prefix. + +`render` reports failure through `onError` rather than throwing, so one dead +render cannot take the surrounding surface with it. Exactly one terminal fires +per render: `onComplete` means the last tree delivered stands, `onError` means +it is partial and must not be shown as final. A product that declines the +render, a tree that fails to decode, a closed connection, and a throwing +renderer all arrive as `onError`. An open render holds one worker reference for +the provider's product, released when the stream ends or the disposer runs, so +the product's worker stays up for as long as something is being drawn. + +`publishChatAction` is the path for posted messages, commands and host-drawn +`Actions` buttons. Each action entry point sits behind its own service's access +policy: the renderer refuses a connection that is not a `Worker` execution, and +chat additionally requires a live session. + +Two rules the core cannot check are the host's to keep: send a render context +only for a surface the product's manifest `includes`, and publish a renderer +action only from the current tree of an open render stream. ## Product account addresses diff --git a/js/packages/truapi-host/src/error.ts b/js/packages/truapi-host/src/error.ts index 5dc5674e9..31c1be1cc 100644 --- a/js/packages/truapi-host/src/error.ts +++ b/js/packages/truapi-host/src/error.ts @@ -4,3 +4,8 @@ export function errorMessage(err: unknown): string { if (typeof err === "string") return err; return JSON.stringify(err) ?? String(err); } + +/** Coerce an unknown thrown value into an `Error`, keeping one it already is. */ +export function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(errorMessage(err)); +} diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index 15f7a8d5c..57b0d190f 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -1,6 +1,8 @@ import type { - CustomRendererNode, HostChatActionSubscribeItem, + HostRendererActionSubscribeItem, + ProductRendererRenderRequest, + RendererNode, WireProvider, } from "@parity/truapi"; import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-callbacks.js"; @@ -108,20 +110,12 @@ export interface ProductRuntimeConfig { }; } -/** One stored custom Chat message the host wants the product to draw. */ -export interface CustomMessageRenderRequest { - messageId: string; - /** Selects which of the product's renderers draws the message. */ - messageType: string; - payload: Uint8Array; -} - /** - * Sink for one custom-message render. `onUpdate` receives a complete - * replacement tree each time; there is no patching. + * Sink for one render. `onUpdate` receives a complete replacement tree each + * time; there is no patching. */ -export interface CustomMessageRenderSink { - onUpdate(node: CustomRendererNode): void; +export interface RenderSink { + onUpdate(node: RendererNode): void; /** * The render ended cleanly and the last tree delivered stands. Exactly one * of `onComplete` or `onError` fires per render. @@ -130,8 +124,8 @@ export interface CustomMessageRenderSink { /** * The render failed and any tree already delivered is partial, so it must * not be left on screen as final. Covers a product that declined or could - * not encode a tree, a connection that may not reach Chat or has closed, and - * a tree the host's own codec or renderer rejected. + * not encode a tree, a connection that may not reach the renderer or has + * closed, and a tree the host's own codec or renderer rejected. */ onError?(error: Error): void; } @@ -156,24 +150,34 @@ export interface TrUApiProductProvider extends WireProvider, CoreAdmin { /** * Publish one host-authored Chat action into the product's action stream — - * the path a tapped button in a rendered custom message takes back to the - * product. Buffered until the product subscribes. Rejects when this - * connection may not reach Chat. + * the path a posted message, a command, or a host-drawn `Actions` button + * takes back to the product. Buffered until the product subscribes. Rejects + * when this connection may not reach Chat. * * Present only on runtimes that keep a live channel to the core. */ publishChatAction?(action: HostChatActionSubscribeItem): Promise; /** - * Ask the product to draw one stored custom Chat message, streaming - * replacement trees until the returned disposer is called. Reports failure - * through `sink.onError` rather than throwing, so a dead render never takes - * the host's message list with it. + * Publish one action triggered inside a product-rendered body. Buffered + * until the product subscribes. Rejects when this connection may not reach + * the product's renderer. + * + * Present only on runtimes that keep a live channel to the core. + */ + publishRendererAction?(item: HostRendererActionSubscribeItem): Promise; + + /** + * Ask the product to draw one body, streaming replacement trees until the + * returned disposer is called. Reports failure through `sink.onError` rather + * than throwing, so a dead render never takes the host's surrounding surface + * with it. + * + * An open render holds one worker reference for the provider's product: the + * runtime acquires it when the stream starts and releases it when the stream + * ends or the disposer runs. * * Present only on runtimes that keep a live channel to the core. */ - renderCustomMessage?( - request: CustomMessageRenderRequest, - sink: CustomMessageRenderSink, - ): () => void; + render?(request: ProductRendererRenderRequest, sink: RenderSink): () => void; } diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index ac8bb5929..5a921152f 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -5,7 +5,8 @@ import type { PermissionAuthorizationRuntime } from "./worker-permission-authorization.js"; -export interface WorkerCustomRendererSubscription { +/** Cancellable handle on one live render stream inside the core. */ +export interface WorkerRendererSubscription { cancel(): void; free(): void; } @@ -18,19 +19,25 @@ export interface WorkerProductRuntime { /** Throws when the connection may not reach Chat. */ publishChatAction(action: Uint8Array): void; /** - * Start the host-initiated render subscription for one stored custom Chat - * message. `onUpdate` receives each SCALE-encoded `CustomRendererNode`, then - * exactly one of `onComplete` (last tree stands) or `onError` (the product - * could not serve the render; the last tree is partial). + * Publish one action triggered inside a product-rendered body, as a + * SCALE-encoded `HostRendererActionSubscribeItem`. Throws when the + * connection may not reach the product's renderer. */ - renderCustomMessage( - messageId: string, - messageType: string, - payload: Uint8Array, + publishRendererAction(item: Uint8Array): void; + /** + * Start the host-initiated render subscription for one body. `request` is a + * SCALE-encoded `ProductRendererRenderRequest`. `onUpdate` receives each + * SCALE-encoded `RendererNode`, then exactly one of `onComplete` (last tree + * stands) or `onError` (the product could not serve the render; the last + * tree is partial). Terminals never arrive during the call itself; a request + * the core refuses outright throws instead. + */ + render( + request: Uint8Array, onUpdate: (node: Uint8Array) => void, onComplete: () => void, onError: (reason: string) => void, - ): WorkerCustomRendererSubscription; + ): WorkerRendererSubscription; } /** What the host does with a product's worker after demand on it changed. */ 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 12470bb4d..323550771 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 @@ -11,13 +11,16 @@ import type { } from "../index.js"; import type { Bytes32, - CustomRendererNode, GenericError, HostChatActionSubscribeItem, + HostRendererActionSubscribeItem, + RendererNode, } from "@parity/truapi"; import { - CustomRendererNode as CustomRendererNodeCodec, HostChatActionSubscribeItem as HostChatActionSubscribeItemCodec, + HostRendererActionSubscribeItem as HostRendererActionSubscribeItemCodec, + ProductRendererRenderRequest as ProductRendererRenderRequestCodec, + RendererNode as RendererNodeCodec, } from "@parity/truapi"; import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; @@ -30,7 +33,7 @@ import type { } from "../worker-protocol.js"; import { bytesToHex } from "@parity/truapi/scale"; import { startRawSubscription } from "../generated/worker-callbacks.js"; -import { errorMessage } from "../error.js"; +import { errorMessage, toError } from "../error.js"; export type WebWorkerHostConfig = Omit< ProductRuntimeConfig, @@ -126,6 +129,17 @@ interface CoreState { disposed: boolean; } +/** + * One live render on the main thread. The core id rides along so disposing one + * provider fails only its own renders. + */ +interface RenderEntry { + coreId: number; + onUpdate: (node: RendererNode) => void; + onComplete: () => void; + onError: (error: Error) => void; +} + interface RuntimeState { worker: Worker; rawCallbacks: RawCallbacks; @@ -184,23 +198,13 @@ interface RuntimeState { reject: (error: Error) => void; } >; - pendingChatActions: Map< + /** Host-authored Chat and Renderer actions awaiting the worker's response. */ + pendingActions: Map< number, { resolve: () => void; reject: (error: Error) => void } >; - /** - * Sinks for live custom-message renders, keyed by render id. The core id - * rides along so disposing one provider fails only its own renders. - */ - customRenders: Map< - number, - { - coreId: number; - onUpdate: (node: CustomRendererNode) => void; - onComplete: () => void; - onError: (error: Error) => void; - } - >; + /** Sinks for live renders, keyed by render id. */ + renders: Map; /** Products whose worker the core currently wants, for late subscribers. */ wantedWorkers: Set; workerDemandListeners: Set<(change: WorkerDemandChange) => void>; @@ -221,8 +225,8 @@ let nextSessionChatIdentityKeyRequestId = 0; let nextDeviceEncryptionKeyRequestId = 0; let nextProductSubtreePublicKeyRequestId = 0; let nextSessionActivationRequestId = 0; -let nextChatActionRequestId = 0; -let nextCustomRenderId = 0; +let nextActionRequestId = 0; +let nextRenderId = 0; function encodePermissionAuthorizationRequest( request: PermissionAuthorizationRequest, @@ -279,7 +283,8 @@ function readPersistedDebuggerUrl(): DebuggerEnablement { // (tsc output run under Node, unit tests), where the access throws. let dev = false; try { - dev = (import.meta as unknown as { env: { DEV?: boolean } }).env.DEV === true; + dev = + (import.meta as unknown as { env: { DEV?: boolean } }).env.DEV === true; } catch { dev = false; } @@ -344,7 +349,9 @@ function reportDebuggerEnablement(e: DebuggerEnablement): void { } const origin = globalThis.location?.origin ?? "(unknown origin)"; if (e.reason === "enabled") { - console.info(`[truapi] wire debugger: dialling ${e.url} (origin ${origin})`); + console.info( + `[truapi] wire debugger: dialling ${e.url} (origin ${origin})`, + ); return; } const why = @@ -684,10 +691,10 @@ function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void { rejectAll(state.pendingSessionChatIdentityKeys, error); rejectAll(state.pendingDeviceEncryptionKeys, error); rejectAll(state.pendingProductSubtreePublicKeys, error); - rejectAll(state.pendingChatActions, error); - for (const [renderId, sink] of [...state.customRenders]) { - state.customRenders.delete(renderId); - reportRenderFailure(sink, error); + rejectAll(state.pendingActions, error); + for (const renderId of [...state.renders.keys()]) { + const sink = takeRender(state, renderId); + if (sink) reportRenderFailure(sink, error); } for (const pending of state.pendingCores.values()) { pending.reject(error); @@ -819,8 +826,8 @@ export function createWebWorkerPairingHostRuntime( pendingSessionChatIdentityKeys: new Map(), pendingProductSubtreePublicKeys: new Map(), pendingDeviceEncryptionKeys: new Map(), - pendingChatActions: new Map(), - customRenders: new Map(), + pendingActions: new Map(), + renders: new Map(), wantedWorkers: new Set(), workerDemandListeners: new Set(), closedError: null, @@ -899,33 +906,33 @@ export function createWebWorkerPairingHostRuntime( } break; case "publishChatActionResponse": + case "publishRendererActionResponse": settlePending( - state.pendingChatActions, + state.pendingActions, msg.requestId, msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, ); break; - case "renderCustomMessageItem": { - const sink = state.customRenders.get(msg.renderId); + case "renderItem": { + const sink = state.renders.get(msg.renderId); if (!sink) break; // Escaping the listener would strand the render with no terminal. try { - sink.onUpdate(CustomRendererNodeCodec.dec(msg.node)); + sink.onUpdate(RendererNodeCodec.dec(msg.node)); } catch (err) { - state.customRenders.delete(msg.renderId); + takeRender(state, msg.renderId); state.worker.postMessage({ - kind: "renderCustomMessageStop", + kind: "renderStop", renderId: msg.renderId, } satisfies MainToWorker); reportRenderFailure(sink, err); } break; } - case "renderCustomMessageComplete": { - const sink = state.customRenders.get(msg.renderId); - state.customRenders.delete(msg.renderId); + case "renderComplete": { + const sink = takeRender(state, msg.renderId); try { sink?.onComplete(); } catch (err) { @@ -933,9 +940,8 @@ export function createWebWorkerPairingHostRuntime( } break; } - case "renderCustomMessageError": { - const sink = state.customRenders.get(msg.renderId); - state.customRenders.delete(msg.renderId); + case "renderError": { + const sink = takeRender(state, msg.renderId); if (sink) reportRenderFailure(sink, new Error(msg.error)); break; } @@ -1084,8 +1090,12 @@ function handleFrameError( console.error("[truapi worker]", error); const core = state.cores.get(coreId); if (!core) return; - closeCoreState(core, new Error(`worker frame error: ${error}`)); + const failure = new Error(`worker frame error: ${error}`); + closeCoreState(core, failure); state.cores.delete(coreId); + // Renders left registered would never settle: the worker cancels them with + // the core, so nothing further arrives to complete the sink. + failRendersForCore(state, coreId, failure); try { state.worker.postMessage({ kind: "disposeCore", @@ -1320,29 +1330,72 @@ function handleWorkerDemandChanged( /** Deliver a render failure without letting the sink's own throw escape. */ function reportRenderFailure( - sink: { onError: (error: Error) => void }, + sink: { onError?: (error: Error) => void }, cause: unknown, ): void { try { - sink.onError(cause instanceof Error ? cause : new Error(errorMessage(cause))); + sink.onError?.(toError(cause)); } catch (err) { console.warn("[truapi worker] render onError threw:", err); } } +/** + * Drop one render from the ledger, returning its sink only the first time, + * which is what keeps a render settled exactly once. + */ +function takeRender( + state: RuntimeState, + renderId: number, +): RenderEntry | undefined { + const entry = state.renders.get(renderId); + if (!entry) return undefined; + state.renders.delete(renderId); + return entry; +} + /** Settle and drop every render belonging to one product connection. */ function failRendersForCore( state: RuntimeState, coreId: number, error: Error, ): void { - for (const [renderId, sink] of [...state.customRenders]) { - if (sink.coreId !== coreId) continue; - state.customRenders.delete(renderId); - reportRenderFailure(sink, error); + for (const [renderId, entry] of [...state.renders]) { + if (entry.coreId !== coreId) continue; + const sink = takeRender(state, renderId); + if (sink) reportRenderFailure(sink, error); } } +/** + * Post one host-authored action to the worker and settle on its response. + * Encoding runs before registering, so a payload the codec rejects leaves no + * pending entry behind. + */ +function publishAction( + state: RuntimeState, + core: CoreState, + kind: "publishChatAction" | "publishRendererAction", + encode: () => Uint8Array, +): Promise { + if (state.disposed || core.disposed) { + return Promise.reject(new Error("product connection is closed")); + } + let action: Uint8Array; + try { + action = encode(); + } catch (err) { + return Promise.reject(toError(err)); + } + return sendWorkerRequest( + state, + state.pendingActions, + () => nextActionRequestId++, + undefined, + (requestId) => ({ kind, coreId: core.coreId, requestId, action }), + ); +} + function buildProvider( state: RuntimeState, core: CoreState, @@ -1429,44 +1482,56 @@ function buildProvider( runtime.setLogLevel(level); }, publishChatAction(action: HostChatActionSubscribeItem): Promise { - if (state.disposed || core.disposed) { - return Promise.reject(new Error("product connection is closed")); - } - const requestId = nextChatActionRequestId++; - return new Promise((resolve, reject) => { - state.pendingChatActions.set(requestId, { resolve, reject }); - state.worker.postMessage({ - kind: "publishChatAction", - coreId: core.coreId, - requestId, - action: HostChatActionSubscribeItemCodec.enc(action), - } satisfies MainToWorker); - }); + return publishAction(state, core, "publishChatAction", () => + HostChatActionSubscribeItemCodec.enc(action), + ); }, - renderCustomMessage(request, sink) { + publishRendererAction( + item: HostRendererActionSubscribeItem, + ): Promise { + return publishAction(state, core, "publishRendererAction", () => + HostRendererActionSubscribeItemCodec.enc(item), + ); + }, + render(request, sink) { if (state.disposed || core.disposed) { sink.onError?.(new Error("product connection is closed")); return () => {}; } - const renderId = nextCustomRenderId++; - state.customRenders.set(renderId, { + // Encode before registering, so a request the codec rejects leaves no + // render behind that the worker was never told about. + let encoded: Uint8Array; + try { + encoded = ProductRendererRenderRequestCodec.enc(request); + } catch (err) { + reportRenderFailure(sink, err); + return () => {}; + } + const renderId = nextRenderId++; + // No worker reference is taken here: the core holds the one an open + // render is worth and reports it through `workerDemandChanged`. + state.renders.set(renderId, { coreId: core.coreId, - onUpdate: sink.onUpdate, + onUpdate: (node) => sink.onUpdate(node), onComplete: () => sink.onComplete?.(), onError: (error) => sink.onError?.(error), }); - state.worker.postMessage({ - kind: "renderCustomMessageStart", - coreId: core.coreId, - renderId, - messageId: request.messageId, - messageType: request.messageType, - payload: request.payload, - } satisfies MainToWorker); + try { + state.worker.postMessage({ + kind: "renderStart", + coreId: core.coreId, + renderId, + request: encoded, + } satisfies MainToWorker); + } catch (err) { + const failed = takeRender(state, renderId); + if (failed) reportRenderFailure(failed, err); + return () => {}; + } return () => { - if (!state.customRenders.delete(renderId)) return; + if (!takeRender(state, renderId)) return; state.worker.postMessage({ - kind: "renderCustomMessageStop", + kind: "renderStop", renderId, } satisfies MainToWorker); }; 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 02e601f38..c4d79a4a6 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it } from "bun:test"; import { err, ok } from "neverthrow"; import { - CustomRendererNode, HostPushNotificationRequest, HostPushNotificationResponse, + RendererNode, } from "@parity/truapi"; import { bytesToHex } from "@parity/truapi/scale"; -import type { GenericError, Result, ThemeVariant } from "@parity/truapi"; +import type { + GenericError, + ProductRendererRenderRequest, + Result, + ThemeVariant, +} from "@parity/truapi"; import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; import { AuthState, CoreStorageKey } from "../generated/host-callbacks.js"; @@ -119,6 +124,23 @@ function hostConfigFromRuntimeConfig( return hostConfig; } +/** One render request the provider-level render tests reuse. */ +function renderRequest(): ProductRendererRenderRequest { + return { + context: { tag: "PocketCard", value: { cardId: "card" } }, + payload: "0x", + }; +} + +/** Position of the last message of `kind` in post order, or -1 if never sent. */ +function indexOfKind(worker: FakeWorker, kind: string): number { + let index = -1; + worker.messages.forEach((message, at) => { + if (message.kind === kind) index = at; + }); + return index; +} + function lastMessageOfKind(worker: FakeWorker, kind: string): WorkerMessage { const message = [...worker.messages].reverse().find((m) => m.kind === kind); expect(message).toBeDefined(); @@ -1161,24 +1183,20 @@ describe("createWebWorkerPairingHostRuntime", () => { it("ends a render whose tree cannot be decoded instead of stranding it", async () => { const worker = new FakeWorker(); const provider = await readyProvider(worker); - const coreId = lastMessageOfKind(worker, "createCore").coreId; const errors: Error[] = []; let completed = 0; - provider.renderCustomMessage!( - { messageId: "m", messageType: "vote", payload: new Uint8Array() }, - { - onUpdate: () => {}, - onComplete: () => completed++, - onError: (error) => errors.push(error), - }, - ); - const { renderId } = lastMessageOfKind(worker, "renderCustomMessageStart"); + provider.render!(renderRequest(), { + onUpdate: () => {}, + onComplete: () => completed++, + onError: (error) => errors.push(error), + }); + const { renderId } = lastMessageOfKind(worker, "renderStart"); - // 0xff is not a CustomRendererNode discriminant. + // 0xff is not a RendererNode discriminant. expect(() => worker.emit({ - kind: "renderCustomMessageItem", + kind: "renderItem", renderId, node: new Uint8Array([0xff]), }), @@ -1187,33 +1205,105 @@ describe("createWebWorkerPairingHostRuntime", () => { expect(errors).toHaveLength(1); expect(completed).toBe(0); // The worker must be told to stop, or its wasm subscription leaks. - expect(lastMessageOfKind(worker, "renderCustomMessageStop").renderId).toBe( - renderId, - ); + expect(lastMessageOfKind(worker, "renderStop").renderId).toBe(renderId); }); - it("keeps a throwing render sink from breaking the worker listener", async () => { + it("fails a render the codec rejects without registering it", async () => { const worker = new FakeWorker(); const provider = await readyProvider(worker); - provider.renderCustomMessage!( - { messageId: "m", messageType: "vote", payload: new Uint8Array() }, + const errors: Error[] = []; + const stop = provider.render!( + // Odd-length hex: a `HexString` the codec cannot turn into bytes. { - onUpdate: () => { - throw new Error("renderer exploded"); - }, - onError: () => { - throw new Error("and so did onError"); - }, + context: { tag: "PocketCard", value: { cardId: "card" } }, + payload: "0xabc", }, + { onUpdate: () => {}, onError: (error) => errors.push(error) }, ); - const { renderId } = lastMessageOfKind(worker, "renderCustomMessageStart"); + + expect(errors).toHaveLength(1); + // Nothing was registered, so nothing is left for the worker to stop. + expect(indexOfKind(worker, "renderStart")).toBe(-1); + stop(); + expect(indexOfKind(worker, "renderStop")).toBe(-1); + }); + + it("settles a render exactly once when postMessage throws for renderStart", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + const post = worker.postMessage.bind(worker); + worker.postMessage = (message: WorkerMessage) => { + if (message.kind === "renderStart") { + throw new Error("worker gone"); + } + post(message); + }; + + const errors: Error[] = []; + const stop = provider.render!(renderRequest(), { + onUpdate: () => {}, + onError: (error) => errors.push(error), + }); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch(/worker gone/); + // The post never reached the worker, so there is nothing to stop. + expect(indexOfKind(worker, "renderStart")).toBe(-1); + expect(indexOfKind(worker, "renderStop")).toBe(-1); + + // The returned disposer is a safe no-op: the ledger entry is already gone. + expect(() => stop()).not.toThrow(); + expect(indexOfKind(worker, "renderStop")).toBe(-1); + expect(errors).toHaveLength(1); + + // A later core disposal must not settle the already-settled sink again. + provider.dispose(); + expect(errors).toHaveLength(1); + }); + + it("fails every open render of a core the worker reported a frame error for", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + const errors: Error[] = []; + provider.render!(renderRequest(), { + onUpdate: () => {}, + onError: (error) => errors.push(error), + }); + const { coreId } = lastMessageOfKind(worker, "renderStart"); + + worker.emit({ kind: "frameError", coreId, error: "bad frame" }); + + // The worker cancels the subscription with the core, so the sink's only + // terminal is this one. + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch(/worker frame error: bad frame/); + + provider.dispose(); + expect(errors).toHaveLength(1); + }); + + it("keeps a throwing render sink from breaking the worker listener", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + provider.render!(renderRequest(), { + onUpdate: () => { + throw new Error("renderer exploded"); + }, + onError: () => { + throw new Error("and so did onError"); + }, + }); + const { renderId } = lastMessageOfKind(worker, "renderStart"); expect(() => worker.emit({ - kind: "renderCustomMessageItem", + kind: "renderItem", renderId, - node: CustomRendererNode.enc({ tag: "Nil", value: undefined }), + node: RendererNode.enc({ tag: "Nil", value: undefined }), }), ).not.toThrow(); @@ -1222,6 +1312,125 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "frame", coreId: 0, bytes: new Uint8Array([1]) }), ).not.toThrow(); }); + + it("leaves the render's worker reference to the core", async () => { + const worker = new FakeWorker(); + const runtime = await readyRuntime(worker); + const provider = await finishProviderReady( + worker, + runtime.createProvider({ productId: "dotli.dot" }), + ); + const seen: WorkerDemandChange[] = []; + runtime.subscribeWorkerDemand((change) => seen.push(change)); + + const stop = provider.render!(renderRequest(), { onUpdate: () => {} }); + + // The core holds the reference, so this thread asks for none of its own. + expect(indexOfKind(worker, "acquireWorker")).toBe(-1); + // The demand the core's own reference caused still reaches the host. + worker.emit({ + kind: "workerDemandChanged", + productId: "dotli.dot", + wanted: true, + }); + expect(seen).toEqual([{ productId: "dotli.dot", wanted: true }]); + + stop(); + expect(indexOfKind(worker, "releaseWorker")).toBe(-1); + worker.emit({ + kind: "workerDemandChanged", + productId: "dotli.dot", + wanted: false, + }); + expect(seen).toEqual([ + { productId: "dotli.dot", wanted: true }, + { productId: "dotli.dot", wanted: false }, + ]); + runtime.dispose(); + }); + + it("binds a method-valued onUpdate to its own receiver", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + class RecordingSink { + nodes: RendererNode[] = []; + onUpdate(node: RendererNode) { + this.nodes.push(node); + } + } + const sink = new RecordingSink(); + + provider.render!(renderRequest(), sink); + const { renderId } = lastMessageOfKind(worker, "renderStart"); + + expect(() => + worker.emit({ + kind: "renderItem", + renderId, + node: RendererNode.enc({ tag: "Nil", value: undefined }), + }), + ).not.toThrow(); + + expect(sink.nodes).toHaveLength(1); + expect(sink.nodes[0]).toEqual({ tag: "Nil", value: undefined }); + }); + + it("publishes a renderer action and settles on the worker's response", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + const published = provider.publishRendererAction!({ + context: { tag: "PocketCard", value: { cardId: "card" } }, + actionId: "confirm", + payload: "0x", + }); + const { requestId } = lastMessageOfKind(worker, "publishRendererAction"); + worker.emit({ kind: "publishRendererActionResponse", requestId, ok: true }); + + await expect(published).resolves.toBeUndefined(); + }); + + it("publishes a chat action and settles on the worker's response", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + const published = provider.publishChatAction!({ + roomId: "room", + peer: "peer", + payload: { + tag: "ActionTriggered", + value: { messageId: "message", actionId: "confirm" }, + }, + }); + const { requestId } = lastMessageOfKind(worker, "publishChatAction"); + worker.emit({ kind: "publishChatActionResponse", requestId, ok: true }); + + await expect(published).resolves.toBeUndefined(); + }); + + it("rejects a chat action the worker could not publish", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + const published = provider.publishChatAction!({ + roomId: "room", + peer: "peer", + payload: { + tag: "ActionTriggered", + value: { messageId: "message", actionId: "confirm" }, + }, + }); + const { requestId } = lastMessageOfKind(worker, "publishChatAction"); + worker.emit({ + kind: "publishChatActionResponse", + requestId, + ok: false, + error: "Denied", + }); + + await expect(published).rejects.toThrow("Denied"); + }); }); describe("debugger enablement reporting", () => { diff --git a/js/packages/truapi-host/src/worker-actions.test.ts b/js/packages/truapi-host/src/worker-actions.test.ts new file mode 100644 index 000000000..c1979c750 --- /dev/null +++ b/js/packages/truapi-host/src/worker-actions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "bun:test"; + +import { + CHAT_ACTION_ENTRY_POINT, + RENDERER_ACTION_ENTRY_POINT, + handlePublishAction, + type ActionEntryPoint, +} from "./worker-actions.js"; +import type { WorkerProductRuntime } from "./wasm-module.js"; +import type { WorkerToMain } from "./worker-protocol.js"; + +function fakeCore(log: string[]): WorkerProductRuntime { + return { + receiveFrame: async () => {}, + dispose: () => {}, + free: () => {}, + publishChatAction: (action) => log.push(`chat:${action.join(",")}`), + publishRendererAction: (item) => log.push(`renderer:${item.join(",")}`), + render: () => ({ cancel: () => {}, free: () => {} }), + }; +} + +/** Each entry point with the core method it calls and the kind it answers in. */ +const entryPoints: { + entryPoint: ActionEntryPoint; + responseKind: string; + logged: string; + deny: (core: WorkerProductRuntime) => void; +}[] = [ + { + entryPoint: CHAT_ACTION_ENTRY_POINT, + responseKind: "publishChatActionResponse", + logged: "chat:7", + deny: (core) => { + core.publishChatAction = () => { + throw new Error("Denied"); + }; + }, + }, + { + entryPoint: RENDERER_ACTION_ENTRY_POINT, + responseKind: "publishRendererActionResponse", + logged: "renderer:7", + deny: (core) => { + core.publishRendererAction = () => { + throw new Error("Denied"); + }; + }, + }, +]; + +describe("worker action entry points", () => { + for (const { entryPoint, responseKind, logged, deny } of entryPoints) { + describe(entryPoint.name, () => { + it("answers for an unknown core instead of throwing", () => { + const messages: WorkerToMain[] = []; + + handlePublishAction( + entryPoint, + undefined, + (msg) => messages.push(msg), + 4, + 9, + new Uint8Array([1]), + ); + + expect(messages).toEqual([ + { + kind: responseKind, + requestId: 9, + ok: false, + error: `${entryPoint.name} received for unknown core 4`, + }, + ] as WorkerToMain[]); + }); + + it("reports a core that refuses the action rather than dropping it", () => { + const messages: WorkerToMain[] = []; + const core = fakeCore([]); + deny(core); + + handlePublishAction( + entryPoint, + core, + (msg) => messages.push(msg), + 1, + 3, + new Uint8Array([7]), + ); + + expect(messages).toEqual([ + { kind: responseKind, requestId: 3, ok: false, error: "Denied" }, + ] as WorkerToMain[]); + }); + + it("hands an accepted action to its own core method", () => { + const messages: WorkerToMain[] = []; + const log: string[] = []; + + handlePublishAction( + entryPoint, + fakeCore(log), + (msg) => messages.push(msg), + 1, + 2, + new Uint8Array([7]), + ); + + expect(log).toEqual([logged]); + expect(messages).toEqual([ + { kind: responseKind, requestId: 2, ok: true }, + ] as WorkerToMain[]); + }); + }); + } +}); diff --git a/js/packages/truapi-host/src/worker-actions.ts b/js/packages/truapi-host/src/worker-actions.ts new file mode 100644 index 000000000..3a2b2fc8f --- /dev/null +++ b/js/packages/truapi-host/src/worker-actions.ts @@ -0,0 +1,55 @@ +// Worker half of the host-authored action entry points. Both call the core +// directly rather than going through the frame path, which carries product +// requests only. + +import type { WorkerProductRuntime } from "./wasm-module.js"; +import type { WorkerToMain } from "./worker-protocol.js"; +import { errorMessage } from "./error.js"; + +type PostToMain = (msg: WorkerToMain) => void; + +/** One host-authored action stream, as the worker sees it. */ +export interface ActionEntryPoint { + /** Request kind the host posts; the response kind is this plus `Response`. */ + name: "publishChatAction" | "publishRendererAction"; + publish: (core: WorkerProductRuntime, item: Uint8Array) => void; +} + +/** The Chat action stream, carrying a `HostChatActionSubscribeItem`. */ +export const CHAT_ACTION_ENTRY_POINT: ActionEntryPoint = { + name: "publishChatAction", + publish: (core, item) => core.publishChatAction(item), +}; + +/** The Renderer action stream, carrying a `HostRendererActionSubscribeItem`. */ +export const RENDERER_ACTION_ENTRY_POINT: ActionEntryPoint = { + name: "publishRendererAction", + publish: (core, item) => core.publishRendererAction(item), +}; + +/** Hand one host-authored action to the core and answer the caller. */ +export function handlePublishAction( + entryPoint: ActionEntryPoint, + core: WorkerProductRuntime | undefined, + postToMain: PostToMain, + coreId: number, + requestId: number, + item: Uint8Array, +): void { + const kind = `${entryPoint.name}Response` as const; + if (!core) { + postToMain({ + kind, + requestId, + ok: false, + error: `${entryPoint.name} received for unknown core ${coreId}`, + }); + return; + } + try { + entryPoint.publish(core, item); + postToMain({ kind, requestId, ok: true }); + } catch (err) { + postToMain({ kind, requestId, ok: false, error: errorMessage(err) }); + } +} diff --git a/js/packages/truapi-host/src/worker-chat.ts b/js/packages/truapi-host/src/worker-chat.ts deleted file mode 100644 index 0d479407a..000000000 --- a/js/packages/truapi-host/src/worker-chat.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Worker half of the two host-initiated Chat entry points. Both reach the core -// directly rather than through the frame path, because neither is a product -// request: the host starts the render subscription, and the host publishes the -// action a rendered cell produced. - -import type { - WorkerCustomRendererSubscription, - WorkerProductRuntime, -} from "./wasm-module.js"; -import type { WorkerToMain } from "./worker-protocol.js"; -import { errorMessage } from "./error.js"; - -type PostToMain = (msg: WorkerToMain) => void; - -/** - * Live render subscriptions, keyed by the main thread's render id. The core id - * rides along so disposing one core cancels only its own renders. - */ -export type RenderSubscriptions = Map< - number, - { coreId: number; subscription: WorkerCustomRendererSubscription } ->; - -export function handlePublishChatAction( - core: WorkerProductRuntime | undefined, - postToMain: PostToMain, - coreId: number, - requestId: number, - action: Uint8Array, -): void { - if (!core) { - postToMain({ - kind: "publishChatActionResponse", - requestId, - ok: false, - error: `publishChatAction received for unknown core ${coreId}`, - }); - return; - } - try { - core.publishChatAction(action); - postToMain({ kind: "publishChatActionResponse", requestId, ok: true }); - } catch (err) { - postToMain({ - kind: "publishChatActionResponse", - requestId, - ok: false, - error: errorMessage(err), - }); - } -} - -export function handleRenderCustomMessageStart( - core: WorkerProductRuntime | undefined, - postToMain: PostToMain, - renders: RenderSubscriptions, - coreId: number, - renderId: number, - messageId: string, - messageType: string, - payload: Uint8Array, -): void { - if (!core) { - postToMain({ - kind: "renderCustomMessageError", - renderId, - error: `renderCustomMessage received for unknown core ${coreId}`, - }); - return; - } - try { - const subscription = core.renderCustomMessage( - messageId, - messageType, - payload, - (node) => postToMain({ kind: "renderCustomMessageItem", renderId, node }), - () => { - stopRender(renders, renderId); - postToMain({ kind: "renderCustomMessageComplete", renderId }); - }, - (reason) => { - stopRender(renders, renderId); - postToMain({ - kind: "renderCustomMessageError", - renderId, - error: reason, - }); - }, - ); - renders.set(renderId, { coreId, subscription }); - } catch (err) { - postToMain({ - kind: "renderCustomMessageError", - renderId, - error: errorMessage(err), - }); - } -} - -/** Cancel and release one render subscription. Idempotent. */ -export function stopRender( - renders: RenderSubscriptions, - renderId: number, -): void { - const entry = renders.get(renderId); - if (!entry) return; - renders.delete(renderId); - entry.subscription.cancel(); - entry.subscription.free(); -} - -/** Cancel every render belonging to one core, before that core is freed. */ -export function stopRendersForCore( - renders: RenderSubscriptions, - coreId: number, -): void { - for (const [renderId, entry] of [...renders]) { - if (entry.coreId === coreId) stopRender(renders, renderId); - } -} diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index cdb88c456..2d272494d 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -113,17 +113,24 @@ export type MainToWorker = kind: "publishChatAction"; coreId: number; requestId: number; + /** SCALE-encoded `HostChatActionSubscribeItem`. */ action: Uint8Array; } | { - kind: "renderCustomMessageStart"; + kind: "publishRendererAction"; + coreId: number; + requestId: number; + /** SCALE-encoded `HostRendererActionSubscribeItem`. */ + action: Uint8Array; + } + | { + kind: "renderStart"; coreId: number; renderId: number; - messageId: string; - messageType: string; - payload: Uint8Array; + /** SCALE-encoded `ProductRendererRenderRequest`. */ + request: Uint8Array; } - | { kind: "renderCustomMessageStop"; renderId: number } + | { kind: "renderStop"; renderId: number } | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } | { kind: "callbackResponse"; requestId: number; ok: false; error: string } | { kind: "subscriptionItem"; subId: number; value: unknown } @@ -257,11 +264,18 @@ export type WorkerToMain = * the latest message for a product is its current level. */ | { kind: "workerDemandChanged"; productId: string; wanted: boolean } - /** One replacement tree, as a SCALE-encoded `CustomRendererNode`. */ - | { kind: "renderCustomMessageItem"; renderId: number; node: Uint8Array } + | { kind: "publishRendererActionResponse"; requestId: number; ok: true } + | { + kind: "publishRendererActionResponse"; + requestId: number; + ok: false; + error: string; + } + /** One replacement tree, as a SCALE-encoded `RendererNode`. */ + | { kind: "renderItem"; renderId: number; node: Uint8Array } /** The product ended the render stream; no further items follow. */ - | { kind: "renderCustomMessageComplete"; renderId: number } - | { kind: "renderCustomMessageError"; renderId: number; error: string } + | { kind: "renderComplete"; renderId: number } + | { kind: "renderError"; renderId: number; error: string } | { kind: "callbackRequest"; requestId: number; diff --git a/js/packages/truapi-host/src/worker-chat.test.ts b/js/packages/truapi-host/src/worker-renderer.test.ts similarity index 65% rename from js/packages/truapi-host/src/worker-chat.test.ts rename to js/packages/truapi-host/src/worker-renderer.test.ts index 70a8c36a9..6a2236022 100644 --- a/js/packages/truapi-host/src/worker-chat.test.ts +++ b/js/packages/truapi-host/src/worker-renderer.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from "bun:test"; +import { ProductRendererRenderRequest } from "@parity/truapi"; import { - handlePublishChatAction, - handleRenderCustomMessageStart, + handleRenderStart, stopRender, stopRendersForCore, type RenderSubscriptions, -} from "./worker-chat.js"; +} from "./worker-renderer.js"; import type { - WorkerCustomRendererSubscription, + WorkerRendererSubscription, WorkerProductRuntime, } from "./wasm-module.js"; import type { WorkerToMain } from "./worker-protocol.js"; -function fakeSubscription(log: string[]): WorkerCustomRendererSubscription { +const renderRequest = ProductRendererRenderRequest.enc({ + context: { tag: "PocketCard", value: { cardId: "card" } }, + payload: "0x", +}); + +function fakeSubscription(log: string[]): WorkerRendererSubscription { return { cancel: () => log.push("cancel"), free: () => log.push("free"), @@ -33,66 +38,16 @@ function fakeCore( receiveFrame: async () => {}, dispose: () => {}, free: () => {}, - publishChatAction: (action) => log.push(`publish:${action.join(",")}`), - renderCustomMessage: ( - _id, - _type, - _payload, - onUpdate, - onComplete, - onError, - ) => { + publishChatAction: (action) => log.push(`chat:${action.join(",")}`), + publishRendererAction: (item) => log.push(`renderer:${item.join(",")}`), + render: (_request, onUpdate, onComplete, onError) => { onStart?.({ update: onUpdate, complete: onComplete, fail: onError }); return fakeSubscription(log); }, }; } -describe("worker chat entry points", () => { - it("answers publishChatAction for an unknown core instead of throwing", () => { - const messages: WorkerToMain[] = []; - handlePublishChatAction( - undefined, - (msg) => messages.push(msg), - 4, - 9, - new Uint8Array([1]), - ); - expect(messages).toEqual([ - { - kind: "publishChatActionResponse", - requestId: 9, - ok: false, - error: "publishChatAction received for unknown core 4", - }, - ]); - }); - - it("reports a core that refuses the action rather than dropping it", () => { - const messages: WorkerToMain[] = []; - const core = fakeCore([]); - core.publishChatAction = () => { - throw new Error("Denied"); - }; - - handlePublishChatAction( - core, - (msg) => messages.push(msg), - 1, - 3, - new Uint8Array([7]), - ); - - expect(messages).toEqual([ - { - kind: "publishChatActionResponse", - requestId: 3, - ok: false, - error: "Denied", - }, - ]); - }); - +describe("worker render subscription", () => { it("streams render items and releases the subscription on complete", () => { const messages: WorkerToMain[] = []; const log: string[] = []; @@ -103,15 +58,13 @@ describe("worker chat entry points", () => { fail: (reason: string) => void; }; - handleRenderCustomMessageStart( + handleRenderStart( fakeCore(log, (e) => (emit = e)), (msg) => messages.push(msg), renders, 1, 5, - "message", - "vote", - new Uint8Array([1]), + renderRequest, ); expect(renders.has(5)).toBe(true); @@ -119,18 +72,37 @@ describe("worker chat entry points", () => { emit.complete(); expect(messages).toEqual([ - { - kind: "renderCustomMessageItem", - renderId: 5, - node: new Uint8Array([2, 3]), - }, - { kind: "renderCustomMessageComplete", renderId: 5 }, + { kind: "renderItem", renderId: 5, node: new Uint8Array([2, 3]) }, + { kind: "renderComplete", renderId: 5 }, ]); // Completing must free the wasm handle, not just stop delivering. expect(log).toEqual(["cancel", "free"]); expect(renders.has(5)).toBe(false); }); + it("reports a render for an unknown core as a render error", () => { + const messages: WorkerToMain[] = []; + const renders: RenderSubscriptions = new Map(); + + handleRenderStart( + undefined, + (msg) => messages.push(msg), + renders, + 7, + 2, + renderRequest, + ); + + expect(messages).toEqual([ + { + kind: "renderError", + renderId: 2, + error: "render received for unknown core 7", + }, + ]); + expect(renders.size).toBe(0); + }); + it("cancels only the renders belonging to the disposed core", () => { const log: string[] = []; const renders: RenderSubscriptions = new Map([ @@ -155,6 +127,7 @@ describe("worker chat entry points", () => { expect(log).toEqual(["cancel", "free"]); }); + it("reports a declined render as an error, not a completion", () => { const messages: WorkerToMain[] = []; const log: string[] = []; @@ -165,15 +138,13 @@ describe("worker chat entry points", () => { fail: (reason: string) => void; }; - handleRenderCustomMessageStart( + handleRenderStart( fakeCore(log, (e) => (emit = e)), (msg) => messages.push(msg), renders, 1, 6, - "message", - "vote", - new Uint8Array(), + renderRequest, ); emit.update(new Uint8Array([9])); @@ -181,13 +152,9 @@ describe("worker chat entry points", () => { // The partial tree must be followed by an error, never a completion. expect(messages).toEqual([ + { kind: "renderItem", renderId: 6, node: new Uint8Array([9]) }, { - kind: "renderCustomMessageItem", - renderId: 6, - node: new Uint8Array([9]), - }, - { - kind: "renderCustomMessageError", + kind: "renderError", renderId: 6, error: "product interrupted the host-initiated subscription", }, diff --git a/js/packages/truapi-host/src/worker-renderer.ts b/js/packages/truapi-host/src/worker-renderer.ts new file mode 100644 index 000000000..96a6231e3 --- /dev/null +++ b/js/packages/truapi-host/src/worker-renderer.ts @@ -0,0 +1,87 @@ +// Worker half of the host-initiated render subscription. It calls the core +// directly rather than going through the frame path, which carries product +// requests only. + +import type { + WorkerRendererSubscription, + WorkerProductRuntime, +} from "./wasm-module.js"; +import type { WorkerToMain } from "./worker-protocol.js"; +import { errorMessage } from "./error.js"; + +type PostToMain = (msg: WorkerToMain) => void; + +/** + * Live render subscriptions, keyed by the main thread's render id. The core id + * rides along so disposing one core cancels only its own renders. + */ +export type RenderSubscriptions = Map< + number, + { coreId: number; subscription: WorkerRendererSubscription } +>; + +/** + * Open one render stream on the core and forward its items to the main thread. + * Exactly one terminal is posted per render: the core rejects a request it + * cannot start by throwing, and otherwise delivers the terminal asynchronously. + */ +export function handleRenderStart( + core: WorkerProductRuntime | undefined, + postToMain: PostToMain, + renders: RenderSubscriptions, + coreId: number, + renderId: number, + request: Uint8Array, +): void { + if (!core) { + postToMain({ + kind: "renderError", + renderId, + error: `render received for unknown core ${coreId}`, + }); + return; + } + try { + const subscription = core.render( + request, + (node) => postToMain({ kind: "renderItem", renderId, node }), + () => { + stopRender(renders, renderId); + postToMain({ kind: "renderComplete", renderId }); + }, + (reason) => { + stopRender(renders, renderId); + postToMain({ kind: "renderError", renderId, error: reason }); + }, + ); + renders.set(renderId, { coreId, subscription }); + } catch (err) { + postToMain({ + kind: "renderError", + renderId, + error: errorMessage(err), + }); + } +} + +/** Cancel and release one render subscription. Idempotent. */ +export function stopRender( + renders: RenderSubscriptions, + renderId: number, +): void { + const entry = renders.get(renderId); + if (!entry) return; + renders.delete(renderId); + entry.subscription.cancel(); + entry.subscription.free(); +} + +/** Cancel every render belonging to one core, before that core is freed. */ +export function stopRendersForCore( + renders: RenderSubscriptions, + coreId: number, +): void { + for (const [renderId, entry] of [...renders]) { + if (entry.coreId === coreId) stopRender(renders, renderId); + } +} diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 6bebac822..4b3d5b5e3 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -29,12 +29,16 @@ import type { } from "./wasm-module.js"; import { errorMessage } from "./error.js"; import { - handlePublishChatAction, - handleRenderCustomMessageStart, + CHAT_ACTION_ENTRY_POINT, + RENDERER_ACTION_ENTRY_POINT, + handlePublishAction, +} from "./worker-actions.js"; +import { + handleRenderStart, stopRender, stopRendersForCore, type RenderSubscriptions, -} from "./worker-chat.js"; +} from "./worker-renderer.js"; import { dispatchChainResponse, dispatchSubscriptionError, @@ -635,7 +639,7 @@ const cores = new Map(); // core for the whole duration of an async method, so `free()` throws while one // is in flight. `disposeCore` aborts these then awaits them before freeing. const inFlightFrames = new Map>>(); -/** Live custom-message render subscriptions, keyed by main-thread render id. */ +/** Live render subscriptions, keyed by main-thread render id. */ const renders: RenderSubscriptions = new Map(); let wasm: WasmModuleShape | null = null; @@ -841,7 +845,8 @@ ctx.addEventListener("message", (ev: MessageEvent) => { break; } case "publishChatAction": - handlePublishChatAction( + handlePublishAction( + CHAT_ACTION_ENTRY_POINT, cores.get(msg.coreId), postToMain, msg.coreId, @@ -849,19 +854,27 @@ ctx.addEventListener("message", (ev: MessageEvent) => { msg.action, ); break; - case "renderCustomMessageStart": - handleRenderCustomMessageStart( + case "publishRendererAction": + handlePublishAction( + RENDERER_ACTION_ENTRY_POINT, + cores.get(msg.coreId), + postToMain, + msg.coreId, + msg.requestId, + msg.action, + ); + break; + case "renderStart": + handleRenderStart( cores.get(msg.coreId), postToMain, renders, msg.coreId, msg.renderId, - msg.messageId, - msg.messageType, - msg.payload, + msg.request, ); break; - case "renderCustomMessageStop": + case "renderStop": stopRender(renders, msg.renderId); break; case "disposeCore": diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 1f2616498..96ffd9740 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -125,25 +125,25 @@ function accountGetResponsePayload( function rendererStart( requestId: string, - request: T.ProductChatCustomMessageRenderRequest, + request: T.ProductRendererRenderRequest, ): Uint8Array { return wireFrame( requestId, - W.CHAT_CUSTOM_MESSAGE_RENDER, + W.RENDERER_RENDER, MESSAGE_TYPE_START, - T.VersionedProductChatCustomMessageRenderRequest.enc({ + T.VersionedProductRendererRenderRequest.enc({ tag: "V1", value: request, }), ); } -function rendererReceive(requestId: string, node: T.CustomRendererNode): Uint8Array { +function rendererReceive(requestId: string, node: T.RendererNode): Uint8Array { return wireFrame( requestId, - W.CHAT_CUSTOM_MESSAGE_RENDER, + W.RENDERER_RENDER, MESSAGE_TYPE_RECEIVE, - T.VersionedProductChatCustomMessageRenderItem.enc({ + T.VersionedProductRendererRenderItem.enc({ tag: "V1", value: node, }), @@ -159,7 +159,7 @@ function rendererReceive(requestId: string, node: T.CustomRendererNode): Uint8Ar function rendererInterrupt(requestId: string): Uint8Array { return wireFrame( requestId, - W.CHAT_CUSTOM_MESSAGE_RENDER, + W.RENDERER_RENDER, MESSAGE_TYPE_INTERRUPT, new Uint8Array([ 1, 4, 44, 117, 110, 97, 118, 97, 105, 108, 97, 98, 108, 101, @@ -174,7 +174,7 @@ function rendererTypedInterrupt( ): Uint8Array { return wireFrame( requestId, - W.CHAT_CUSTOM_MESSAGE_RENDER, + W.RENDERER_RENDER, MESSAGE_TYPE_INTERRUPT, S.Result(S._void, S.CallError(T.GenericError)).enc({ success: false, @@ -187,7 +187,7 @@ function rendererTypedInterrupt( function rendererCleanInterrupt(requestId: string): Uint8Array { return wireFrame( requestId, - W.CHAT_CUSTOM_MESSAGE_RENDER, + W.RENDERER_RENDER, MESSAGE_TYPE_INTERRUPT, S.Result(S._void, S.CallError(T.GenericError)).enc({ success: true, @@ -197,7 +197,7 @@ function rendererCleanInterrupt(requestId: string): Uint8Array { } function rendererStop(requestId: string): Uint8Array { - return wireFrame(requestId, W.CHAT_CUSTOM_MESSAGE_RENDER, MESSAGE_TYPE_STOP); + return wireFrame(requestId, W.RENDERER_RENDER, MESSAGE_TYPE_STOP); } function protocolError(requestId: string, payload: Uint8Array): Uint8Array { @@ -546,15 +546,15 @@ describe("generated client transport", () => { fixture.receive( // No handler is ever registered in this test (no client is created), // so this never reaches a typed decode of the rest. - wireFrame("h:known", W.CHAT_CUSTOM_MESSAGE_RENDER, MESSAGE_TYPE_START), + wireFrame("h:known", W.RENDERER_RENDER, MESSAGE_TYPE_START), ); expect(fixture.sent.map(toHex)).toEqual([ toHex( unsupportedMessage( "h:known", - W.CHAT_CUSTOM_MESSAGE_RENDER.trait, - W.CHAT_CUSTOM_MESSAGE_RENDER.method, + W.RENDERER_RENDER.trait, + W.RENDERER_RENDER.method, ), ), ]); @@ -863,16 +863,18 @@ describe("generated client transport", () => { it("buffers a host render start until the product registers its handler", () => { const fixture = providerFixture(); const client = createClient(createTransport(fixture.provider)); - const request: T.ProductChatCustomMessageRenderRequest = { - messageId: "message-1", - messageType: "vote", + const request: T.ProductRendererRenderRequest = { + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: "message-1", messageType: "vote" }, + }, payload: "0x0102", }; // Legacy hosts use opaque ids rather than the Rust host's `h:` prefix. fixture.receive(rendererStart("legacy-render-1", request)); - const handled: T.ProductChatCustomMessageRenderRequest[] = []; - client.chat.onCustomMessageRender((value) => { + const handled: T.ProductRendererRenderRequest[] = []; + client.renderer.onRender((value) => { handled.push(value); }); @@ -883,15 +885,17 @@ describe("generated client transport", () => { it("streams complete replacement trees on the host-owned request id", () => { const fixture = providerFixture(); const client = createClient(createTransport(fixture.provider)); - let send: ((node: T.CustomRendererNode) => void) | undefined; - client.chat.onCustomMessageRender((_request, sendItem) => { + let send: ((node: T.RendererNode) => void) | undefined; + client.renderer.onRender((_request, sendItem) => { send = sendItem; }); fixture.receive( rendererStart("h:7", { - messageId: "message-7", - messageType: "vote", + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: "message-7", messageType: "vote" }, + }, payload: "0x", }), ); @@ -908,14 +912,16 @@ describe("generated client transport", () => { it("declines a render when the handler throws", () => { const fixture = providerFixture(); const client = createClient(createTransport(fixture.provider)); - client.chat.onCustomMessageRender(() => { + client.renderer.onRender(() => { throw new Error("unsupported renderer"); }); fixture.receive( rendererStart("h:2", { - messageId: "message-2", - messageType: "unknown", + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: "message-2", messageType: "unknown" }, + }, payload: "0x", }), ); @@ -926,14 +932,16 @@ describe("generated client transport", () => { it("ends a render with the interrupt value its handler supplies", () => { const fixture = providerFixture(); const client = createClient(createTransport(fixture.provider)); - client.chat.onCustomMessageRender((_request, _send, interrupt) => { + client.renderer.onRender((_request, _send, interrupt) => { interrupt({ tag: "HostFailure", value: { reason: "renderer failed" } }); }); fixture.receive( rendererStart("h:3", { - messageId: "message-3", - messageType: "vote", + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: "message-3", messageType: "vote" }, + }, payload: "0x", }), ); @@ -952,15 +960,17 @@ describe("generated client transport", () => { const fixture = providerFixture(); const client = createClient(createTransport(fixture.provider)); let disposed = false; - client.chat.onCustomMessageRender((_request, _send, interrupt) => { + client.renderer.onRender((_request, _send, interrupt) => { interrupt(); return () => (disposed = true); }); fixture.receive( rendererStart("h:4", { - messageId: "message-4", - messageType: "vote", + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: "message-4", messageType: "vote" }, + }, payload: "0x", }), ); @@ -975,8 +985,10 @@ describe("generated client transport", () => { for (let index = 1; index <= 65; index += 1) { fixture.receive( rendererStart(`h:${index}`, { - messageId: `message-${index}`, - messageType: "vote", + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: `message-${index}`, messageType: "vote" }, + }, payload: "0x", }), ); @@ -990,20 +1002,26 @@ describe("generated client transport", () => { const fixture = providerFixture(); const client = createClient(createTransport(fixture.provider)); const disposed: string[] = []; - client.chat.onCustomMessageRender( - (request) => () => disposed.push(request.messageId), - ); + client.renderer.onRender((request) => () => { + if (request.context.tag === "ChatMessage") { + disposed.push(request.context.value.messageId); + } + }); fixture.receive( rendererStart("h:1", { - messageId: "one", - messageType: "vote", + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: "one", messageType: "vote" }, + }, payload: "0x", }), ); fixture.receive( rendererStart("h:2", { - messageId: "two", - messageType: "vote", + context: { + tag: "ChatMessage", + value: { roomId: "room", messageId: "two", messageType: "vote" }, + }, payload: "0x", }), ); diff --git a/js/packages/truapi/src/playground/services-types.test.ts b/js/packages/truapi/src/playground/services-types.test.ts index 0d95adfef..3639d58d6 100644 --- a/js/packages/truapi/src/playground/services-types.test.ts +++ b/js/packages/truapi/src/playground/services-types.test.ts @@ -29,12 +29,11 @@ describe("servicesForExecution", () => { }); test("generated metadata identifies host-initiated subscriptions", () => { - const chat = generatedServices.find(({ name }) => name === "Chat"); + const renderer = generatedServices.find(({ name }) => name === "Renderer"); + expect(renderer?.requiredExecution).toBe("Worker"); + expect(renderer?.methods.find(({ name }) => name === "render")?.hostInitiated).toBe(true); expect( - chat?.methods.find(({ name }) => name === "custom_message_render")?.hostInitiated, - ).toBe(true); - expect( - chat?.methods.find(({ name }) => name === "action_subscribe")?.hostInitiated, + renderer?.methods.find(({ name }) => name === "action_subscribe")?.hostInitiated, ).toBeUndefined(); }); }); diff --git a/playground/CLAUDE.md b/playground/CLAUDE.md index 277169618..d24bce80c 100644 --- a/playground/CLAUDE.md +++ b/playground/CLAUDE.md @@ -61,7 +61,7 @@ The Diagnosis screen emits a per-host markdown report via "Copy report". Aggrega | `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 Worker executables. | | `worker/index.ts` | Coordinates the Chat diagnosis over the generated Chat API, from a `Worker` execution. | -| `worker/diagnosis.ts` | Owns ordered Chat-only result state and renders both Markdown and native custom-renderer trees. | +| `worker/diagnosis.ts` | Owns ordered Chat-only result state and renders both Markdown and native 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. | diff --git a/playground/README.md b/playground/README.md index b03a15575..18683be25 100644 --- a/playground/README.md +++ b/playground/README.md @@ -16,10 +16,10 @@ The playground is an interactive reference for the App-compatible TrUAPI surface - **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 `Worker` executable that tests room creation and idempotency, bot registration and - idempotency, live room-list updates, text and custom messages, user actions, - and host-initiated custom-render streams. It - displays live results in Chat and posts a Chat-only Markdown report after - `!diagnose` completes the action check. + idempotency, live room-list updates, text and renderer-drawn messages, user + actions, and host-initiated `Renderer` streams. It displays live results in + Chat and posts a Chat-only Markdown report after `!diagnose` completes the + action check. ## Local development diff --git a/playground/tests/unit/chat-diagnosis.test.ts b/playground/tests/unit/chat-diagnosis.test.ts index 866ca004d..8bdbf72e5 100644 --- a/playground/tests/unit/chat-diagnosis.test.ts +++ b/playground/tests/unit/chat-diagnosis.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test"; import { services as generatedServices } from "@parity/truapi/playground/services"; import { servicesForExecution } from "@parity/truapi/playground/services-types"; -import { CHAT_DIAGNOSIS_METHODS, ChatDiagnosis } from "../../worker/diagnosis"; +import { WORKER_DIAGNOSIS_METHODS, ChatDiagnosis } from "../../worker/diagnosis"; describe("ChatDiagnosis", () => { // Expectation comes from codegen, so a missing method fails here. - test("covers every generated Chat method", () => { + test("covers every generated Worker method", () => { const generated = servicesForExecution(generatedServices, "Worker") .filter((service) => service.requiredExecution === "Worker") .flatMap((service) => @@ -13,19 +13,19 @@ describe("ChatDiagnosis", () => { ); expect(generated.length).toBeGreaterThan(0); - expect([...CHAT_DIAGNOSIS_METHODS].sort()).toEqual(generated.sort()); + expect([...WORKER_DIAGNOSIS_METHODS].sort()).toEqual(generated.sort()); }); test("renders a Chat-only report over every tracked method", () => { const diagnosis = new ChatDiagnosis(); - for (const id of CHAT_DIAGNOSIS_METHODS) { + for (const id of WORKER_DIAGNOSIS_METHODS) { diagnosis.pass(id, "worked"); } expect(diagnosis.isComplete()).toBe(true); expect(diagnosis.markdown()).toContain("## Truapi Chat Diagnosis"); expect(diagnosis.markdown()).toContain( - `**${CHAT_DIAGNOSIS_METHODS.length} success · 0 failed**`, + `**${WORKER_DIAGNOSIS_METHODS.length} success · 0 failed**`, ); expect(diagnosis.markdown()).not.toContain("Storage/"); }); diff --git a/playground/tests/unit/worker-renderer-action-diagnosis.test.ts b/playground/tests/unit/worker-renderer-action-diagnosis.test.ts new file mode 100644 index 000000000..24a53ec5c --- /dev/null +++ b/playground/tests/unit/worker-renderer-action-diagnosis.test.ts @@ -0,0 +1,165 @@ +import { expect, mock, test } from "bun:test"; +import { okAsync } from "neverthrow"; +import type { + ChatMessageContent, + HostChatActionSubscribeItem, + HostChatCreateRoomRequest, + HostChatCreateRoomResponse, + HostChatListSubscribeItem, + HostChatPostMessageRequest, + HostChatPostMessageResponse, + HostChatRegisterBotResponse, + HostRendererActionSubscribeItem, + Observer, + ProductRendererRenderRequest, + RendererNode, + TrUApiClient, +} from "@parity/truapi"; + +/** + * Mirrors the ES Observable interop key each generated subscription method + * exposes (`Symbol.observable`, falling back to `"@@observable"` when the + * well-known symbol is absent), so these fakes are recognized by `rxjs`' + * `from(...)` exactly like a real generated `ObservableLike`. + */ +const OBSERVABLE_INTEROP: symbol | string = + (typeof Symbol === "function" && (Symbol as { observable?: symbol }).observable) || + "@@observable"; + +function fakeObservable() { + const observers = new Set>>(); + const observable = { + subscribe(observer: Partial> = {}) { + observers.add(observer); + return { + unsubscribe: () => observers.delete(observer), + subscriptionId: "fake-subscription", + }; + }, + [OBSERVABLE_INTEROP as typeof Symbol.observable]() { + return observable; + }, + }; + return { + observable, + emit(value: Item): void { + for (const observer of observers) observer.next?.(value); + }, + }; +} + +/** + * Guards the one row no automated run can satisfy: if + * `Renderer/action_subscribe` ever waits for a delivered action instead of the + * open subscription, it stays `running` and the final report never posts. + */ +test("worker diagnosis completes after !diagnose alone, without a renderer action", async () => { + const chatActions = fakeObservable(); + const chatRooms = fakeObservable(); + const rendererActions = fakeObservable(); + + const postMessageCalls: HostChatPostMessageRequest[] = []; + let createRoomCalls = 0; + let registerBotCalls = 0; + let onRenderHandler: + | (( + request: ProductRendererRenderRequest, + send: (node: RendererNode) => void, + interrupt: (reason?: unknown) => void, + ) => (() => void) | void) + | undefined; + + const client = { + chat: { + createRoom(request: HostChatCreateRoomRequest) { + createRoomCalls += 1; + // First call is the static Playground room; the rest are the + // per-run diagnostic room (New, then Exists). + if (createRoomCalls === 1) { + return okAsync({ status: "Exists" }); + } + const status = createRoomCalls === 2 ? "New" : "Exists"; + if (status === "New") { + chatRooms.emit({ + rooms: [{ roomId: request.roomId, participatingAs: "RoomHost" }], + }); + } + return okAsync({ status }); + }, + registerBot() { + registerBotCalls += 1; + return okAsync({ + status: registerBotCalls === 1 ? "New" : "Exists", + }); + }, + listSubscribe: () => chatRooms.observable, + postMessage(request: HostChatPostMessageRequest) { + postMessageCalls.push(request); + const payload: ChatMessageContent = request.payload; + if (payload.tag === "Custom") { + const messageId = "custom-message-id"; + // Stand in for the host asking the product to render the custom + // message it just posted. + onRenderHandler?.( + { + context: { + tag: "ChatMessage", + value: { + roomId: request.roomId, + messageId, + messageType: payload.value.messageType, + }, + }, + payload: payload.value.payload, + }, + () => {}, + () => {}, + ); + return okAsync({ messageId }); + } + return okAsync({ + messageId: `text-message-${postMessageCalls.length}`, + }); + }, + actionSubscribe: () => chatActions.observable, + }, + renderer: { + onRender(handler: typeof onRenderHandler) { + onRenderHandler = handler; + return { unsubscribe() {} }; + }, + actionSubscribe: () => rendererActions.observable, + }, + }; + + mock.module("@parity/truapi/sandbox", () => ({ + getClientSync: () => client as unknown as TrUApiClient, + })); + + await import("../../worker/index"); + + // Startup already posted the text and custom messages; no renderer action + // has been delivered by anyone. + expect(postMessageCalls.length).toBe(2); + + const chatRoomId = postMessageCalls[0]?.roomId; + chatActions.emit({ + roomId: chatRoomId ?? "", + peer: "diagnosis-peer", + payload: { + tag: "MessagePosted", + value: { tag: "Text", value: { text: "!diagnose" } }, + }, + }); + + const finalReport = postMessageCalls.at(-1); + if (finalReport?.payload.tag !== "Text") { + throw new Error("expected the final diagnosis report as a Text message"); + } + const report = finalReport.payload.value.text; + + expect(report).toContain("## Truapi Chat Diagnosis"); + expect(report).not.toContain("❌"); + expect(report).toMatch(/`Renderer\/action_subscribe` \| ✅ \| renderer action stream is open/); + expect(report).not.toContain("received a renderer action for the diagnosis message"); +}); diff --git a/playground/worker/diagnosis.ts b/playground/worker/diagnosis.ts index 89d8732ae..8268466f5 100644 --- a/playground/worker/diagnosis.ts +++ b/playground/worker/diagnosis.ts @@ -1,20 +1,21 @@ -import type { CustomRendererNode } from "@parity/truapi"; +import type { RendererNode } from "@parity/truapi"; import { renderDiagnosisMarkdown, type DiagnosisResult, } from "../shared/diagnosis"; /** Pinned against the generated service metadata by `chat-diagnosis.test.ts`. */ -export const CHAT_DIAGNOSIS_METHODS = [ +export const WORKER_DIAGNOSIS_METHODS = [ "Chat/create_room", "Chat/register_bot", "Chat/list_subscribe", "Chat/post_message", "Chat/action_subscribe", - "Chat/custom_message_render", + "Renderer/render", + "Renderer/action_subscribe", ] as const; -export type ChatDiagnosisMethod = (typeof CHAT_DIAGNOSIS_METHODS)[number]; +export type WorkerDiagnosisMethod = (typeof WORKER_DIAGNOSIS_METHODS)[number]; export const CHAT_DIAGNOSIS_REFRESH_ACTION = "truapi-chat-diagnosis-refresh"; export const CHAT_DIAGNOSIS_COPY_ACTION = "truapi-chat-diagnosis-copy"; @@ -34,31 +35,31 @@ const STATUS_ICON = { } as const; export class ChatDiagnosis { - readonly #results = new Map(); + readonly #results = new Map(); readonly #onChange: () => void; #copyStatus: CopyStatus = "idle"; constructor(onChange: () => void = () => {}) { this.#onChange = onChange; - for (const id of CHAT_DIAGNOSIS_METHODS) { + for (const id of WORKER_DIAGNOSIS_METHODS) { this.#results.set(id, { id, status: "running" }); } } - pass(id: ChatDiagnosisMethod, details: string): void { + pass(id: WorkerDiagnosisMethod, 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 { + fail(id: WorkerDiagnosisMethod, 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) { + for (const id of WORKER_DIAGNOSIS_METHODS) { if (this.#results.get(id)?.status === "running") { this.#results.set(id, { id, status: "fail", details }); } @@ -67,7 +68,7 @@ export class ChatDiagnosis { } results(): DiagnosisResult[] { - return CHAT_DIAGNOSIS_METHODS.map((id) => ({ ...this.#results.get(id)! })); + return WORKER_DIAGNOSIS_METHODS.map((id) => ({ ...this.#results.get(id)! })); } isComplete(): boolean { @@ -92,7 +93,7 @@ export class ChatDiagnosis { this.#onChange(); } - rendererNode(): CustomRendererNode { + rendererNode(): RendererNode { const results = this.results(); const passed = results.filter(({ status }) => status === "pass").length; const failed = results.filter(({ status }) => status === "fail").length; @@ -131,7 +132,7 @@ function errorDetails(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function column(children: CustomRendererNode[]): CustomRendererNode { +function column(children: RendererNode[]): RendererNode { return { tag: "Column", value: { @@ -145,7 +146,7 @@ function column(children: CustomRendererNode[]): CustomRendererNode { function text( value: string, style: "HeadlineLarge" | "BodyMediumRegular" | "BodySmallRegular", -): CustomRendererNode { +): RendererNode { return { tag: "Text", value: { @@ -156,7 +157,7 @@ function text( }; } -function button(text: string, clickAction: string): CustomRendererNode { +function button(text: string, clickAction: string): RendererNode { return { tag: "Button", value: { diff --git a/playground/worker/index.ts b/playground/worker/index.ts index df817f559..f514c2ecb 100644 --- a/playground/worker/index.ts +++ b/playground/worker/index.ts @@ -2,12 +2,13 @@ import { getClientSync } from "@parity/truapi/sandbox"; import { bytesToHex, hexToBytes } from "@parity/truapi/scale"; import type { CallErrorValue, - CustomRendererNode, GenericError, HostChatActionSubscribeItem, HostChatListSubscribeItem, + HostRendererActionSubscribeItem, ObservableLike, - ProductChatCustomMessageRenderRequest, + ProductRendererRenderRequest, + RendererNode, } from "@parity/truapi"; import { filter, firstValueFrom, from, timeout } from "rxjs"; import { @@ -33,11 +34,12 @@ if (!client) { throw new Error("TrUAPI Playground Chat worker requires a host connection"); } const chat = client.chat; +const renderer = client.renderer; let customMessageId: string | undefined; let finalReportPosted = false; type RenderInstance = { - request: ProductChatCustomMessageRenderRequest; - send: (node: CustomRendererNode) => void; + messageId: string; + send: (node: RendererNode) => void; interrupt: (reason?: CallErrorValue) => void; disposed: boolean; }; @@ -55,7 +57,7 @@ const diagnosis = new ChatDiagnosis(() => { void publishFinalReportIfComplete(); }); -chat.onCustomMessageRender(handleRenderRequest); +renderer.onRender(handleRenderRequest); chat.actionSubscribe().subscribe({ next(action) { @@ -68,6 +70,37 @@ chat.actionSubscribe().subscribe({ }, }); +renderer.actionSubscribe().subscribe({ + next(action) { + void handleRendererAction(action).catch((error: unknown) => { + diagnosis.fail("Renderer/action_subscribe", error); + }); + }, + error(error) { + diagnosis.fail("Renderer/action_subscribe", error); + }, +}); +// Only a human press produces a renderer action, so the row passes on the +// open subscription; an action that does arrive replaces this detail. +diagnosis.pass( + "Renderer/action_subscribe", + "renderer action stream is open; a press inside the rendered tree is delivered on it", +); + +async function handleRendererAction( + action: HostRendererActionSubscribeItem, +): Promise { + if (action.context.tag !== "ChatMessage") return; + if (action.context.value.messageId !== customMessageId) return; + diagnosis.pass( + "Renderer/action_subscribe", + "received a renderer action for the diagnosis message", + ); + if (action.actionId === CHAT_DIAGNOSIS_REFRESH_ACTION) renderActiveMessages(); + else if (action.actionId === CHAT_DIAGNOSIS_COPY_ACTION) + await copyDiagnosisReport(); +} + await runStartupDiagnosis().catch((error: unknown) => { diagnosis.failPending(error); console.error( @@ -178,12 +211,16 @@ async function ensureRoom(roomId: string, name: string): Promise { } function handleRenderRequest( - request: ProductChatCustomMessageRenderRequest, - send: (node: CustomRendererNode) => void, + request: ProductRendererRenderRequest, + send: (node: RendererNode) => void, interrupt: (reason?: CallErrorValue) => void, ): () => void { - if (request.messageType !== RENDER_MESSAGE_TYPE) { - throw new Error(`unsupported custom message type: ${request.messageType}`); + if (request.context.tag !== "ChatMessage") { + throw new Error(`unsupported renderer context: ${request.context.tag}`); + } + const { messageId, messageType } = request.context.value; + if (messageType !== RENDER_MESSAGE_TYPE) { + throw new Error(`unsupported message type: ${messageType}`); } const payload = JSON.parse( new TextDecoder().decode(hexToBytes(request.payload)), @@ -202,7 +239,7 @@ function handleRenderRequest( throw new Error("render request did not preserve the custom payload"); } - const instance: RenderInstance = { request, send, interrupt, disposed: false }; + const instance: RenderInstance = { messageId, send, interrupt, disposed: false }; if (customMessageId) activateRenderInstance(instance); else pendingRenderInstances.add(instance); return () => { @@ -221,11 +258,11 @@ function activatePendingRenderInstances(): void { function activateRenderInstance(instance: RenderInstance): void { if (instance.disposed) return; - if (instance.request.messageId !== customMessageId) { + if (instance.messageId !== customMessageId) { instance.interrupt({ tag: "HostFailure", value: { - reason: `render request message ${instance.request.messageId} did not match ${customMessageId}`, + reason: `render request message ${instance.messageId} did not match ${customMessageId}`, }, }); return; @@ -233,7 +270,7 @@ function activateRenderInstance(instance: RenderInstance): void { activeRenderInstances.add(instance); instance.send(diagnosis.rendererNode()); diagnosis.pass( - "Chat/custom_message_render", + "Renderer/render", "served initial and replacement trees on a host-initiated render stream", ); } @@ -248,17 +285,6 @@ function renderActiveMessages(): void { 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; diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 20e89f503..18f1ec298 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -65,6 +65,7 @@ const TRAIT_MODULE_MAP: &[(&str, &str)] = &[ ("Payment", "payment"), ("Permissions", "permissions"), ("Preimage", "preimage"), + ("Renderer", "renderer"), ("ResourceAllocation", "resource_allocation"), ("Signing", "signing"), ("StatementStore", "statement_store"), diff --git a/rust/crates/truapi-codegen/src/rust/dispatcher.rs b/rust/crates/truapi-codegen/src/rust/dispatcher.rs index 319a239d9..0f89f4d04 100644 --- a/rust/crates/truapi-codegen/src/rust/dispatcher.rs +++ b/rust/crates/truapi-codegen/src/rust/dispatcher.rs @@ -137,10 +137,10 @@ fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result { /// Emit the free functions that start a host-initiated subscription (a /// method the host calls into the product, e.g. -/// `chat_custom_message_render`). Its `Start` payload is the request -/// wrapper's own encoding, sent immediately rather than registered against -/// the dispatcher; the product's `Receive`/`Interrupt` replies are routed -/// back by [`HostInitiatedSubscriptionManager`]. +/// `renderer_render`). Its `Start` payload is the request wrapper's own +/// encoding, sent immediately rather than registered against the dispatcher; +/// the product's `Receive`/`Interrupt` replies are routed back by +/// [`HostInitiatedSubscriptionManager`]. fn write_host_initiated_callers( out: &mut String, api: &ApiDefinition, diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index 20d8fb6ba..aa6be0939 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -24,6 +24,7 @@ use truapi::api::{ Payment, Permissions, Preimage, + Renderer, ResourceAllocation, Signing, StatementStore, @@ -57,6 +58,7 @@ where register_payment(dispatcher, host.clone()); register_permissions(dispatcher, host.clone()); register_preimage(dispatcher, host.clone()); + register_renderer(dispatcher, host.clone()); register_resource_allocation(dispatcher, host.clone()); register_signing(dispatcher, host.clone()); register_statement_store(dispatcher, host.clone()); @@ -64,14 +66,14 @@ where register_theme(dispatcher, host); } -/// Start the host-initiated `chat_custom_message_render` subscription. -pub(crate) fn chat_custom_message_render( +/// Start the host-initiated `renderer_render` subscription. +pub(crate) fn renderer_render( subscriptions: &HostInitiatedSubscriptionManager, transport: Arc, - request: versioned::chat::ProductChatCustomMessageRenderRequest, -) -> truapi::Subscription> { + request: versioned::renderer::ProductRendererRenderRequest, +) -> truapi::Subscription> { subscriptions.start( - wire_table::CHAT_CUSTOM_MESSAGE_RENDER, + wire_table::RENDERER_RENDER, parity_scale_codec::Encode::encode(&request), transport, ) @@ -1729,6 +1731,48 @@ where } } +fn register_renderer

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Renderer + Send + Sync + 'static, +{ + { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); + let host = host; + dispatcher.on_subscription(wire_table::RENDERER_ACTION_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _request: () = match DecodeAll::decode_all(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Err(subscription_interrupt(error)); + } + }; + let target_version = ::LATEST; + let cx = CallContext::with_request_id(request_id); + if !execution_allowed { + let error: truapi::CallError = truapi::CallError::Denied; + return Err(subscription_interrupt(error)); + } + let stream = host.action_subscribe(&cx).await; + let stream = futures::StreamExt::map( + stream, + move |item: Result>| { + item.map(|item| { + ::from_latest( + truapi::versioned::IntoLatest::into_latest(item), + target_version, + ) + }) + }, + ); + Ok(subscription_stream(stream)) + }) + }); + } +} + fn register_resource_allocation

(dispatcher: &mut Dispatcher, host: Arc

) where P: ResourceAllocation + Send + Sync + 'static, diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 531db9e60..606be57b8 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -42,7 +42,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "9b243e62120ef031"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "e883e2c0b9857933"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { @@ -254,12 +254,6 @@ pub const CHAT_ACTION_SUBSCRIBE: MethodIds = MethodIds { method_id: 4, }; -/// Wire discriminants for `chat_custom_message_render`. -pub const CHAT_CUSTOM_MESSAGE_RENDER: MethodIds = MethodIds { - trait_id: 4, - method_id: 5, -}; - /// Wire discriminants for `coin_payment_create_purse`. pub const COIN_PAYMENT_CREATE_PURSE: MethodIds = MethodIds { trait_id: 5, @@ -488,6 +482,18 @@ pub const LOCALE_SUBSCRIBE: MethodIds = MethodIds { method_id: 0, }; +/// Wire discriminants for `renderer_render`. +pub const RENDERER_RENDER: MethodIds = MethodIds { + trait_id: 17, + method_id: 0, +}; + +/// Wire discriminants for `renderer_action_subscribe`. +pub const RENDERER_ACTION_SUBSCRIBE: MethodIds = MethodIds { + trait_id: 17, + method_id: 1, +}; + /// The full wire table. Trait ids and per-trait method ordering are /// part of the wire protocol; only ever append within a trait. /// Removed methods leave their slot empty. @@ -632,10 +638,6 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "chat_action_subscribe", kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), }, - WireEntry { - method: "chat_custom_message_render", - kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER), - }, WireEntry { method: "coin_payment_create_purse", kind: WireKind::Request(COIN_PAYMENT_CREATE_PURSE), @@ -788,4 +790,12 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "locale_subscribe", kind: WireKind::Subscription(LOCALE_SUBSCRIBE), }, + WireEntry { + method: "renderer_render", + kind: WireKind::Subscription(RENDERER_RENDER), + }, + WireEntry { + method: "renderer_action_subscribe", + kind: WireKind::Subscription(RENDERER_ACTION_SUBSCRIBE), + }, ]; diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 4996efdb9..b9fe09cd7 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -1648,9 +1648,13 @@ reports: Deliberately unavailable methods: -- all six product-initiated Chat methods, because the CLI installs no - `ChatPlatform`; the host-initiated custom-render subscription is also unused - because the CLI has no native Chat UI; +- all five product-initiated Chat methods, because the CLI installs no + `ChatPlatform` for the `App` execution kind these reports run under; +- the product-initiated `Renderer/action_subscribe`, because + `renderer_access_for` grants product Renderer access only to a `Worker` + execution and these reports run the CLI as `App`; the host-initiated + `Renderer` render subscription is also unused because the CLI draws no + product-rendered bodies; - all nine Coin Payment methods, which answer `CallError::Unsupported`; and - all four Payment methods, which answer typed `Unknown` domain errors. diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 8a6f97796..2ac22459c 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -34,7 +34,7 @@ use crate::host_logic::sso::messages::{RemoteMessage, SsoRequestOutcome}; use crate::host_logic::worker::WorkerLedger; use crate::runtime::sso_service::Dispatch; use crate::runtime::{ - ChatConnection, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, LocalActivation, PairedSsoPeer, + ActionChannel, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, LocalActivation, PairedSsoPeer, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, SigningHostRole, SigningHostSsoService, disconnect_paired_host, establish_pairing, respond_to_pairing, resume_pairing, @@ -886,8 +886,8 @@ impl SigningHostRuntime { } /// Adapters scoped to one product connection: the platform serving its -/// syscalls, the optional native Chat adapter, and the connection's Chat -/// stream state. Non-native connections use [`Self::from_services`]. +/// syscalls, the optional native Chat adapter, and the connection's +/// host-fed action streams. Non-native connections use [`Self::from_services`]. #[derive(Clone)] pub(crate) struct ConnectionAdapters { pub(crate) platform: Arc, @@ -897,7 +897,9 @@ pub(crate) struct ConnectionAdapters { /// product execution, so the object that reports OS state has to be the /// same one that presents the prompt. pub(crate) permission_status: Option>, - pub(crate) chat: Arc, + pub(crate) chat: Arc>, + pub(crate) renderer: + Arc>, } impl ConnectionAdapters { @@ -907,7 +909,8 @@ impl ConnectionAdapters { platform: services.platform.clone(), chat_platform: services.chat_platform.clone(), permission_status: services.permission_status_host(), - chat: Arc::new(ChatConnection::new()), + chat: Arc::new(ActionChannel::chat()), + renderer: Arc::new(ActionChannel::renderer()), } } } @@ -933,6 +936,12 @@ pub struct HostAdmin { } impl HostAdmin { + /// Test-only access to the product-facing runtime this handle wraps. + #[cfg(test)] + pub(crate) fn product_runtime(&self) -> &Arc { + &self.product_runtime + } + /// Build an admin handle from a long-lived host runtime and the adapters /// scoped to one product connection. #[instrument(skip_all, fields(runtime.method = "host_admin.new"))] @@ -1152,39 +1161,79 @@ impl ProductRuntimeControl { ) } - /// Request custom-message UI from this connection's product renderer. - pub fn render_custom_message( + /// Publish one action triggered inside a product-rendered body into this + /// connection's renderer action stream, buffering it until the product + /// subscribes. + pub fn publish_renderer_action( &self, - message_id: String, - message_type: String, - payload: Vec, + item: v01::HostRendererActionSubscribeItem, + ) -> Result<(), ProductRuntimeError> { + self.runtime()?.publish_renderer_action( + truapi::versioned::renderer::HostRendererActionSubscribeItem::V1(item), + ) + } + + /// Ask this connection's product to draw one body, streaming replacement + /// trees until the returned subscription is dropped. + /// + /// An open render stream is one reference on the product's worker, held by + /// the core for exactly as long as the returned subscription lives. A + /// transition it causes reaches the host through the observer installed on + /// [`WorkerLedger::install_demand_observer`]. + pub fn render( + &self, + request: v01::ProductRendererRenderRequest, ) -> Result< - truapi::Subscription>, + truapi::Subscription>, ProductRuntimeError, > { - self.runtime()?.native_chat_platform()?; - let request = truapi::versioned::chat::ProductChatCustomMessageRenderRequest::V1( - v01::ProductChatCustomMessageRenderRequest { - message_id, - message_type, - payload, - }, - ); + self.runtime()?.renderer_access()?; + let reference = WorkerReference::acquire(self.runtime.clone()); + let request = truapi::versioned::renderer::ProductRendererRenderRequest::V1(request); let transport: Arc = self.transport.clone(); - let stream = crate::generated::dispatcher::chat_custom_message_render( + let stream = crate::generated::dispatcher::renderer_render( &self.host_subscriptions, transport, request, - ) - .map(|item| { - item.map(|item| match item { - truapi::versioned::chat::ProductChatCustomMessageRenderItem::V1(node) => node, - }) - }); + ); + // The reference lives in the stream's state, so a subscription dropped + // unpolled still releases it. An interrupt ends the stream by contract + // and is not polled past, so it releases there rather than waiting for + // the caller to drop the handle. + let stream = futures::stream::unfold( + (stream, Some(reference)), + |(mut stream, reference)| async move { + match stream.next().await? { + Ok(truapi::versioned::renderer::ProductRendererRenderItem::V1(node)) => { + Some((Ok(node), (stream, reference))) + } + Err(interrupt) => Some((Err(interrupt), (stream, None))), + } + }, + ); Ok(truapi::Subscription::new(stream)) } } +/// One reference the core holds on a product's worker, released on drop. +struct WorkerReference { + runtime: Arc, +} + +impl WorkerReference { + /// Take a reference on the worker of the product `runtime` serves. + fn acquire(runtime: Arc) -> Self { + runtime.acquire_worker_reference(); + Self { runtime } + } +} + +impl Drop for WorkerReference { + fn drop(&mut self) { + self.runtime.release_worker_reference(); + } +} + impl ProductRuntime { /// Build a product-facing host core around a platform implementation and /// outgoing frame sink. @@ -1399,6 +1448,7 @@ impl ProductRuntime { handle.abort(); } self.admin.product_runtime.detach_chat(); + self.admin.product_runtime.detach_renderer(); self.host_subscriptions.close(); self.core.cancel_subscriptions(); } @@ -1521,6 +1571,7 @@ mod tests { PairingBootstrap, derive_x25519_keypair_from_entropy, establish_sso_session_info, x25519_public_key, }; + use crate::host_logic::worker::WorkerTransition; use crate::test_support::{StubPlatform, runtime_config, test_spawner, wait_until}; use parity_scale_codec::Encode; use std::sync::atomic::Ordering; @@ -2103,7 +2154,368 @@ mod tests { } #[test] - fn app_connection_rejects_custom_rendering() { + fn app_connection_rejects_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(v01::ProductRendererRenderRequest { + context: v01::RenderContext::ChatMessage { + room_id: "room".into(), + message_id: "message".into(), + message_type: "vote".into(), + }, + payload: vec![], + }), + Err(ProductRuntimeError::Denied) + )); + } + + #[test] + fn worker_connection_renders_and_receives_actions_without_a_session() { + // Renderer is gated on Worker execution alone, so a signed-out host + // still reaches the product. + let (host_config, _) = runtime_config("worker.dot"); + let product = ProductContext::new_with_execution( + "worker.dot".to_string(), + truapi_platform::ProductExecutionKind::Worker, + ) + .expect("worker product context is valid"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + Arc::new(RecordingSink::default()), + ); + let host = runtime.admin.product_runtime().clone(); + assert!( + host.test_session_state().current().is_none(), + "the fixture must be signed out for this test to mean anything" + ); + + let mut actions = futures::executor::block_on(truapi::api::Renderer::action_subscribe( + host.as_ref(), + &CallContext::with_request_id("renderer:1".to_string()), + )); + + let _render = runtime + .control() + .render(v01::ProductRendererRenderRequest { + context: v01::RenderContext::PocketCard { + card_id: "loyalty".into(), + }, + payload: vec![], + }) + .expect("a signed-out Worker connection may render"); + + let published = v01::HostRendererActionSubscribeItem { + context: v01::RenderContext::PocketCard { + card_id: "loyalty".into(), + }, + action_id: "vote".into(), + payload: vec![], + }; + runtime + .control() + .publish_renderer_action(published.clone()) + .expect("a signed-out Worker connection may receive actions"); + + let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); + let delivered = match actions.poll_next_unpin(&mut cx) { + core::task::Poll::Ready(Some(item)) => item, + other => panic!("a published renderer action must be ready, got {other:?}"), + }; + let Ok(truapi::versioned::renderer::HostRendererActionSubscribeItem::V1(delivered)) = + delivered + else { + panic!("expected a renderer action item") + }; + assert_eq!(delivered, published); + } + + #[test] + fn an_open_render_holds_one_worker_reference() { + // The core owns the reference: no caller above it acquires or releases. + #[derive(Default)] + struct RecordingDemand { + transitions: Mutex>, + } + impl crate::host_logic::worker::WorkerDemandObserver for RecordingDemand { + fn worker_demand_changed(&self, product_id: &str, transition: WorkerTransition) { + self.transitions + .lock() + .expect("transition mutex poisoned") + .push((product_id.to_string(), transition)); + } + } + + let (host_config, _) = runtime_config("worker.dot"); + let product = ProductContext::new_with_execution( + "worker.dot".to_string(), + truapi_platform::ProductExecutionKind::Worker, + ) + .expect("worker product context is valid"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + Arc::new(RecordingSink::default()), + ); + let services = runtime.admin.product_runtime().services().clone(); + let demand = Arc::new(RecordingDemand::default()); + assert!( + services + .worker_ledger + .install_demand_observer(demand.clone()), + "the observer installs once" + ); + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + + let render = runtime + .control() + .render(v01::ProductRendererRenderRequest { + context: v01::RenderContext::PocketCard { + card_id: "loyalty".into(), + }, + payload: vec![], + }) + .expect("a Worker connection may render"); + assert_eq!(services.worker_ledger.count("worker.dot"), 1); + + drop(render); + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + assert_eq!( + demand + .transitions + .lock() + .expect("transition mutex poisoned") + .as_slice(), + [ + ("worker.dot".to_string(), WorkerTransition::Start), + ("worker.dot".to_string(), WorkerTransition::Stop), + ] + ); + } + + /// A signed-out Worker runtime, which may render, and the sink holding the + /// start frames its renders send. + fn render_runtime() -> (ProductRuntime, Arc) { + let (host_config, _) = runtime_config("worker.dot"); + let product = ProductContext::new_with_execution( + "worker.dot".to_string(), + truapi_platform::ProductExecutionKind::Worker, + ) + .expect("worker product context is valid"); + let sink = Arc::new(RecordingSink::default()); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + sink.clone(), + ); + (runtime, sink) + } + + /// Open one render for a Pocket card. + fn start_render( + runtime: &ProductRuntime, + card_id: &str, + ) -> truapi::Subscription> { + runtime + .control() + .render(v01::ProductRendererRenderRequest { + context: v01::RenderContext::PocketCard { + card_id: card_id.to_string(), + }, + payload: vec![], + }) + .expect("a Worker connection may render") + } + + /// Request id of the render start frame sent at `index`. + fn render_request_id(sink: &RecordingSink, index: usize) -> String { + let frames = sink.frames.lock().expect("recording sink mutex poisoned"); + let frame = frames.get(index).expect("the render sent a start frame"); + ProtocolMessage::decode(&mut frame.as_slice()) + .expect("a start frame decodes") + .request_id + } + + /// Deliver one product frame on the render stream `request_id` names. + fn deliver_render_frame( + runtime: &ProductRuntime, + request_id: &str, + message_type: u8, + value: Vec, + ) { + let ids = subscription_ids("renderer_render").expect("known subscription"); + let frame = ProtocolMessage { + request_id: request_id.to_string(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type, + value, + }, + } + .encode(); + futures::executor::block_on(runtime.receive_frame(frame)) + .expect("the product frame is well formed"); + } + + /// Poll one render once with a waker that does nothing. + fn poll_render( + render: &mut truapi::Subscription>, + ) -> core::task::Poll>>> + { + let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); + render.poll_next_unpin(&mut cx) + } + + #[test] + fn a_render_the_product_ends_releases_its_worker_reference() { + let (runtime, sink) = render_runtime(); + let services = runtime.admin.product_runtime().services().clone(); + let mut render = start_render(&runtime, "loyalty"); + assert_eq!(services.worker_ledger.count("worker.dot"), 1); + + let request_id = render_request_id(&sink, 0); + deliver_render_frame( + &runtime, + &request_id, + crate::frame::MESSAGE_TYPE_INTERRUPT, + crate::frame::encode_clean_interrupt(), + ); + assert!(matches!( + poll_render(&mut render), + core::task::Poll::Ready(None) + )); + + assert_eq!( + services.worker_ledger.count("worker.dot"), + 0, + "an ended stream holds no reference, whether or not the handle lives on" + ); + drop(render); + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + } + + #[test] + fn a_render_the_product_interrupts_releases_its_worker_reference() { + let (runtime, sink) = render_runtime(); + let services = runtime.admin.product_runtime().services().clone(); + let mut render = start_render(&runtime, "loyalty"); + + let request_id = render_request_id(&sink, 0); + deliver_render_frame( + &runtime, + &request_id, + crate::frame::MESSAGE_TYPE_INTERRUPT, + Result::<(), truapi::CallError>::Err( + truapi::CallError::HostFailure { + reason: "the product stopped drawing".to_string(), + }, + ) + .encode(), + ); + assert!(matches!( + poll_render(&mut render), + core::task::Poll::Ready(Some(Err(truapi::CallError::HostFailure { .. }))) + )); + + assert_eq!( + services.worker_ledger.count("worker.dot"), + 0, + "an interrupt ends the stream, so the reference goes with it" + ); + drop(render); + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + } + + #[test] + fn a_render_refused_for_a_malformed_tree_releases_its_worker_reference() { + let (runtime, sink) = render_runtime(); + let services = runtime.admin.product_runtime().services().clone(); + let mut render = start_render(&runtime, "loyalty"); + + let request_id = render_request_id(&sink, 0); + deliver_render_frame( + &runtime, + &request_id, + crate::frame::MESSAGE_TYPE_RECEIVE, + vec![0xff], + ); + assert!(matches!( + poll_render(&mut render), + core::task::Poll::Ready(Some(Err(truapi::CallError::MalformedFrame { .. }))) + )); + + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + drop(render); + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + } + + #[test] + fn disposing_the_core_releases_an_open_renders_worker_reference() { + let (runtime, _sink) = render_runtime(); + let services = runtime.admin.product_runtime().services().clone(); + let mut render = start_render(&runtime, "loyalty"); + assert_eq!(services.worker_ledger.count("worker.dot"), 1); + + runtime.dispose(); + assert!(matches!( + poll_render(&mut render), + core::task::Poll::Ready(None) + )); + + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + drop(render); + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + } + + #[test] + fn one_render_ending_leaves_the_other_renders_worker_reference() { + let (runtime, sink) = render_runtime(); + let services = runtime.admin.product_runtime().services().clone(); + let mut first = start_render(&runtime, "loyalty"); + let second = start_render(&runtime, "rewards"); + assert_eq!(services.worker_ledger.count("worker.dot"), 2); + + let request_id = render_request_id(&sink, 0); + deliver_render_frame( + &runtime, + &request_id, + crate::frame::MESSAGE_TYPE_INTERRUPT, + crate::frame::encode_clean_interrupt(), + ); + assert!(matches!( + poll_render(&mut first), + core::task::Poll::Ready(None) + )); + assert_eq!(services.worker_ledger.count("worker.dot"), 1); + + drop(first); + assert_eq!( + services.worker_ledger.count("worker.dot"), + 1, + "dropping a stream that already released must not release again" + ); + + drop(second); + assert_eq!(services.worker_ledger.count("worker.dot"), 0); + } + + #[test] + fn app_connection_rejects_publishing_a_renderer_action() { let (host_config, product) = runtime_config("myapp.dot"); let runtime = ProductRuntime::from_platform_with_config( Arc::new(StubPlatform::default()), @@ -2116,7 +2528,13 @@ mod tests { assert!(matches!( runtime .control() - .render_custom_message("message".into(), "vote".into(), vec![]), + .publish_renderer_action(v01::HostRendererActionSubscribeItem { + context: v01::RenderContext::PocketCard { + card_id: "card".into() + }, + action_id: "vote".into(), + payload: vec![], + }), Err(ProductRuntimeError::Denied) )); } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 373ef46fd..80540c121 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -39,7 +39,7 @@ use crate::host_logic::sso::messages::{ use crate::host_logic::worker::WorkerTransition; #[cfg(feature = "ws-bridge")] use crate::native_renderer::observe_renderer; -use crate::native_renderer::{NativeCustomRendererObserver, NativeCustomRendererSubscription}; +use crate::native_renderer::{NativeRendererObserver, NativeRendererSubscription}; use crate::runtime::sso_remote::sso_message_id; use crate::subscription::Spawner; #[cfg(feature = "ws-bridge")] @@ -672,7 +672,8 @@ impl NativeTrUApiHostRuntime { #[cfg(feature = "ws-bridge")] callbacks, closed: AtomicBool::new(false), - chat_connection: Arc::new(crate::runtime::ChatConnection::new()), + chat_connection: Arc::new(crate::runtime::ActionChannel::chat()), + renderer_connection: Arc::new(crate::runtime::ActionChannel::renderer()), #[cfg(feature = "ws-bridge")] bridge: Mutex::new(None), #[cfg(feature = "ws-bridge")] @@ -970,7 +971,13 @@ pub struct NativeProductExecution { callbacks: Arc, /// Single Chat action buffer shared with every product connection this /// execution opens; survives bridge restarts until [`Self::shutdown`]. - chat_connection: Arc, + chat_connection: + Arc>, + /// Single renderer action buffer shared with every product connection this + /// execution opens; survives bridge restarts until [`Self::shutdown`]. + renderer_connection: Arc< + crate::runtime::ActionChannel, + >, closed: AtomicBool, #[cfg(feature = "ws-bridge")] bridge: Mutex>, @@ -985,6 +992,7 @@ impl NativeProductExecution { chat_platform: self.chat.clone(), permission_status: Some(self.permission_status.clone()), chat: self.chat_connection.clone(), + renderer: self.renderer_connection.clone(), } } @@ -1005,6 +1013,13 @@ impl NativeProductExecution { .map(drop) } + fn require_renderer(&self) -> Result<(), crate::ProductRuntimeError> { + if self.closed.load(Ordering::Acquire) { + return Err(crate::ProductRuntimeError::Closed); + } + crate::runtime::renderer_access_for(self.product.execution_kind) + } + #[cfg(feature = "ws-bridge")] fn stop_bridge(&self) { if let Some(mut bridge) = self @@ -1124,20 +1139,20 @@ impl NativeProductExecution { action: v01::HostChatActionSubscribeItem, ) -> Result<(), crate::ProductRuntimeError> { self.require_chat()?; - self.chat_connection.publish_action( - truapi::versioned::chat::HostChatActionSubscribeItem::V1(action), - ) + self.chat_connection + .publish(truapi::versioned::chat::HostChatActionSubscribeItem::V1( + action, + )) } - /// Request typed native UI for one stored custom Chat message. - pub fn render_custom_message( + /// Ask the product to draw one body, delivering each replacement tree to + /// `observer` until the returned subscription is cancelled. + pub fn render( &self, - message_id: String, - message_type: String, - payload: Vec, - observer: Box, - ) -> Result, crate::ProductRuntimeError> { - self.require_chat()?; + request: v01::ProductRendererRenderRequest, + observer: Box, + ) -> Result, crate::ProductRuntimeError> { + self.require_renderer()?; #[cfg(feature = "ws-bridge")] { let control = self @@ -1146,17 +1161,28 @@ impl NativeProductExecution { .expect("native product control mutex poisoned") .clone() .ok_or(crate::ProductRuntimeError::NotConnected)?; - let stream = control.render_custom_message(message_id, message_type, payload)?; - let observer: Arc = observer.into(); + let stream = control.render(request)?; + 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); + let _ = (request, observer); Err(crate::ProductRuntimeError::NotConnected) } } + /// Publish one action triggered inside a product-rendered body, buffering + /// it until the product connection subscribes. + pub fn publish_renderer_action( + &self, + item: v01::HostRendererActionSubscribeItem, + ) -> Result<(), crate::ProductRuntimeError> { + self.require_renderer()?; + self.renderer_connection + .publish(truapi::versioned::renderer::HostRendererActionSubscribeItem::V1(item)) + } + /// Permanently shut down this executable and all of its connection state. /// /// This is named `shutdown` rather than `close` because UniFFI Kotlin @@ -1169,6 +1195,7 @@ impl NativeProductExecution { #[cfg(feature = "ws-bridge")] self.stop_bridge(); self.chat_connection.close(); + self.renderer_connection.close(); } } @@ -2667,7 +2694,7 @@ mod tests { ProductExecutionKind::Worker, ) .unwrap(); - let connection = crate::runtime::ChatConnection::new(); + let connection = crate::runtime::ActionChannel::chat(); let posted = futures::executor::block_on(truapi_platform::ChatPlatform::post_chat_message( &platform, @@ -2686,9 +2713,9 @@ mod tests { )) .expect("an action set must reach the host"); - let mut actions = connection.subscribe_actions(); + let mut actions = connection.subscribe(); connection - .publish_action(truapi::versioned::chat::HostChatActionSubscribeItem::V1( + .publish(truapi::versioned::chat::HostChatActionSubscribeItem::V1( v01::HostChatActionSubscribeItem { room_id: "support".to_string(), peer: "alice".to_string(), @@ -2731,6 +2758,55 @@ mod tests { assert_eq!(trigger.action_id, "approve"); } + #[test] + fn a_renderer_action_reaches_the_product_that_rendered_it() { + // The channel is execution-scoped, so the admin handle built from this + // execution reads what the execution published. + let host = NativeTrUApiHostRuntime::with_runtime_config( + Arc::new(EventCallbacks::new()), + native_host_runtime_config(), + ) + .expect("host runtime config should be valid"); + let execution = host + .open_product_execution( + Arc::new(EventCallbacks::new()), + None, + native_execution_config("chat.dot", ProductExecutionKind::Worker), + ) + .expect("Worker execution should open"); + + let admin = execution.admin(); + let mut actions = futures::executor::block_on(truapi::api::Renderer::action_subscribe( + admin.product_runtime().as_ref(), + &truapi::CallContext::with_request_id("renderer-1".to_string()), + )); + + let published = v01::HostRendererActionSubscribeItem { + context: v01::RenderContext::ChatMessage { + room_id: "support".to_string(), + message_id: "message-1".to_string(), + message_type: "vote".to_string(), + }, + action_id: "approve".to_string(), + payload: Vec::new(), + }; + execution + .publish_renderer_action(published.clone()) + .expect("a Worker execution may publish renderer actions"); + + let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); + let delivered = match actions.poll_next_unpin(&mut cx) { + core::task::Poll::Ready(Some(item)) => item, + other => panic!("a published renderer action must be ready, got {other:?}"), + }; + let Ok(truapi::versioned::renderer::HostRendererActionSubscribeItem::V1(delivered)) = + delivered + else { + panic!("expected a renderer action item") + }; + assert_eq!(delivered, published); + } + #[test] fn native_chat_adapter_surfaces_a_message_rejection() { let callbacks = Arc::new(EventCallbacks::new()); diff --git a/rust/crates/truapi-server/src/native_renderer.rs b/rust/crates/truapi-server/src/native_renderer.rs index 302bfc55d..1abbaa8f2 100644 --- a/rust/crates/truapi-server/src/native_renderer.rs +++ b/rust/crates/truapi-server/src/native_renderer.rs @@ -1,20 +1,18 @@ -//! Native observation of custom-renderer streams. +//! Native observation of renderer streams. use std::sync::{Arc, Mutex}; use futures::StreamExt; use futures::future::{AbortHandle, Abortable}; -use truapi::{ - CallError, Subscription, latest::GenericError, latest::ProductChatCustomMessageRenderItem, -}; +use truapi::{CallError, Subscription, latest::GenericError, latest::ProductRendererRenderItem}; use crate::subscription::{Spawner, interrupt_reason}; /// Observer implemented by a native host to receive renderer tree replacements. #[uniffi::export(callback_interface)] -pub trait NativeCustomRendererObserver: Send + Sync { +pub trait NativeRendererObserver: Send + Sync { /// Deliver a complete replacement tree. - fn on_update(&self, node: ProductChatCustomMessageRenderItem); + fn on_update(&self, node: ProductRendererRenderItem); /// Report that the renderer stream ended without drawing further trees. /// The last tree delivered stands. @@ -25,14 +23,14 @@ pub trait NativeCustomRendererObserver: Send + Sync { fn on_error(&self, reason: String); } -/// Cancellable native observation of one custom-message render instance. +/// Cancellable native observation of one render instance. #[derive(uniffi::Object)] -pub struct NativeCustomRendererSubscription { +pub struct NativeRendererSubscription { abort: Mutex>, } #[uniffi::export] -impl NativeCustomRendererSubscription { +impl NativeRendererSubscription { /// Stop delivering renderer updates to the native observer. pub fn cancel(&self) { if let Some(abort) = self @@ -46,7 +44,7 @@ impl NativeCustomRendererSubscription { } } -impl Drop for NativeCustomRendererSubscription { +impl Drop for NativeRendererSubscription { fn drop(&mut self) { self.cancel(); } @@ -54,10 +52,10 @@ impl Drop for NativeCustomRendererSubscription { #[cfg_attr(not(feature = "ws-bridge"), allow(dead_code))] pub(crate) fn observe_renderer( - mut stream: Subscription>, - observer: Arc, + mut stream: Subscription>, + observer: Arc, spawner: Spawner, -) -> Arc { +) -> Arc { let (abort, registration) = AbortHandle::new_pair(); (spawner)(Box::pin(async move { let _ = Abortable::new( @@ -74,7 +72,7 @@ pub(crate) fn observe_renderer( ) .await; })); - Arc::new(NativeCustomRendererSubscription { + Arc::new(NativeRendererSubscription { abort: Mutex::new(Some(abort)), }) } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index c876795d2..53b9b8083 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -7,6 +7,8 @@ //! permission cache layer). Methods with no platform backing return //! `CallError::unavailable()`. +/// Connection-scoped, host-fed action streams. +pub(crate) mod actions; mod allowances; /// Core-owned auth/session UI state machine. pub(crate) mod auth_state; @@ -21,6 +23,7 @@ pub(crate) mod login_failure; mod pairing_host; pub(crate) mod product_manifest; mod product_subtree; +mod renderer; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. pub(crate) mod services; @@ -43,13 +46,15 @@ use std::sync::Arc; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; +pub(crate) use actions::ActionChannel; use authority::{AuthorityCancelError, AuthoritySession}; pub(crate) use authority::{AuthorityError, BulletinAllowanceKey, ProductAuthority}; -pub(crate) use chat::{ChatConnection, chat_platform_for}; +pub(crate) use chat::chat_platform_for; use futures::{FutureExt, StreamExt, pin_mut}; #[cfg(test)] use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; +pub(crate) use renderer::renderer_access_for; pub(crate) use services::RuntimeServices; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::StatementRenewalTarget; @@ -59,7 +64,7 @@ pub(crate) use signing_host::{ }; pub use signing_host::{PairedSsoPeer, ResponderExit}; use tracing::{instrument, warn}; -use truapi::api::Chat; +use truapi::api::{Chat, Renderer}; use truapi::latest::GenericError; use truapi::versioned::account::{HostAccountGetError, HostAccountSignVrfError}; use truapi::versioned::chat::{ @@ -69,6 +74,7 @@ use truapi::versioned::chat::{ HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; use truapi::versioned::preimage::RemotePreimageSubmitError; +use truapi::versioned::renderer::HostRendererActionSubscribeItem; use truapi::{CallContext, CallError, CancellationReason, Subscription, v01}; use truapi_platform::{ AccountAccessReview, ChatFieldError, IdentityDisclosureReview, PermissionAuthorizationRequest, @@ -244,7 +250,8 @@ pub struct ProductRuntimeHost { /// Stable per-product-runtime id used to scope long-lived chain follow /// operation ids within one shared host runtime. core_instance: u64, - chat: Arc, + chat: Arc>, + renderer: Arc>, } impl ProductRuntimeHost { @@ -266,6 +273,7 @@ impl ProductRuntimeHost { product, core_instance, chat: adapters.chat, + renderer: adapters.renderer, } } @@ -377,7 +385,8 @@ impl ProductRuntimeHost { ); let pairing_host = PairingHost::new(services.clone(), host_config); let core_instance = services.next_core_instance(); - let chat = Arc::new(ChatConnection::new()); + let chat = Arc::new(ActionChannel::chat()); + let renderer = Arc::new(ActionChannel::renderer()); let host = Self { services, platform, @@ -387,6 +396,7 @@ impl ProductRuntimeHost { product, core_instance, chat, + renderer, }; (host, pairing_host) } @@ -950,7 +960,42 @@ impl ProductRuntimeHost { action: truapi::versioned::chat::HostChatActionSubscribeItem, ) -> Result<(), crate::host_core::ProductRuntimeError> { self.native_chat_platform()?; - self.chat.publish_action(action) + self.chat.publish(action) + } + + /// Renderer access policy for this connection; see [`renderer_access_for`]. + pub(crate) fn renderer_access(&self) -> Result<(), crate::host_core::ProductRuntimeError> { + renderer_access_for(self.product.execution_kind) + } + + /// Take one core-held reference on this connection's product worker, for + /// a body the product is drawing. Pair every call with one + /// [`Self::release_worker_reference`]. + pub(crate) fn acquire_worker_reference(&self) { + self.services + .worker_ledger + .acquire(&self.product.product_id); + } + + /// Release one core-held reference on this connection's product worker. + pub(crate) fn release_worker_reference(&self) { + self.services + .worker_ledger + .release(&self.product.product_id); + } + + /// End the renderer action stream this connection's product is reading. + pub(crate) fn detach_renderer(&self) { + self.renderer.detach(); + } + + /// Buffer one renderer action for this connection's product. + pub(crate) fn publish_renderer_action( + &self, + item: HostRendererActionSubscribeItem, + ) -> Result<(), crate::host_core::ProductRuntimeError> { + self.renderer_access()?; + self.renderer.publish(item) } } @@ -1056,7 +1101,21 @@ impl Chat for ProductRuntimeHost { if let Err(error) = self.chat_platform::() { return Subscription::interrupted(error); } - self.chat.subscribe_actions() + self.chat.subscribe() + } +} + +#[truapi_platform::async_trait] +impl Renderer for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "renderer.action_subscribe"))] + async fn action_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription> { + if self.renderer_access().is_err() { + return Subscription::interrupted(CallError::Denied); + } + self.renderer.subscribe() } } /// Report a rejected chat bot field as a bot-registration domain error. diff --git a/rust/crates/truapi-server/src/runtime/actions.rs b/rust/crates/truapi-server/src/runtime/actions.rs new file mode 100644 index 000000000..5c964771b --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/actions.rs @@ -0,0 +1,202 @@ +//! Connection-scoped, host-fed action streams buffered until the product subscribes. + +use std::collections::VecDeque; +use std::sync::Mutex; + +use futures::StreamExt; +use futures::channel::mpsc; +use truapi::latest::GenericError; +use truapi::{CallError, Subscription}; + +use crate::host_core::ProductRuntimeError; + +const ACTION_BUFFER_CAPACITY: usize = 64; + +struct State { + subscriber: Option>, + buffer: VecDeque, + closed: bool, +} + +impl Default for State { + fn default() -> Self { + Self { + subscriber: None, + buffer: VecDeque::new(), + closed: false, + } + } +} + +/// One product connection's stream of host-authored items of one kind. +pub(crate) struct ActionChannel { + state: Mutex>, + closed_reason: &'static str, +} + +impl ActionChannel { + /// Create an empty channel; `closed_reason` names the stream in the + /// interrupt a late subscriber sees after `close`. + fn new(closed_reason: &'static str) -> Self { + Self { + state: Mutex::new(State::default()), + closed_reason, + } + } + + /// One connection's Chat action stream. + pub(crate) fn chat() -> Self { + Self::new("chat is closed for this product connection") + } + + /// One connection's Renderer action stream. + pub(crate) fn renderer() -> Self { + Self::new("renderer is closed for this product connection") + } + + /// Open the product's subscription and drain buffered items first. + pub(crate) fn subscribe(&self) -> Subscription> { + let (sender, receiver) = mpsc::unbounded(); + let mut state = self.state.lock().expect("action channel mutex poisoned"); + if state.closed { + return Subscription::interrupted(CallError::HostFailure { + reason: self.closed_reason.to_string(), + }); + } + for item in state.buffer.drain(..) { + let _ = sender.unbounded_send(item); + } + state.subscriber = Some(sender); + Subscription::new(receiver.map(Ok)) + } + + /// Publish one item, buffering it until the product subscribes. + pub(crate) fn publish(&self, mut item: Item) -> Result<(), ProductRuntimeError> { + let mut state = self.state.lock().expect("action channel mutex poisoned"); + if state.closed { + return Err(ProductRuntimeError::Closed); + } + if let Some(sender) = state.subscriber.as_ref() { + match sender.unbounded_send(item) { + Ok(()) => return Ok(()), + Err(error) => item = error.into_inner(), + } + state.subscriber = None; + } + if state.buffer.len() == ACTION_BUFFER_CAPACITY { + return Err(ProductRuntimeError::BufferFull); + } + state.buffer.push_back(item); + Ok(()) + } + + /// End the current subscriber's stream while keeping buffered items for + /// the next product connection that subscribes. + pub(crate) fn detach(&self) { + let mut state = self.state.lock().expect("action channel mutex poisoned"); + state.subscriber = None; + } + + /// Close the channel and discard buffered items. + #[cfg(any(test, not(target_arch = "wasm32")))] + pub(crate) fn close(&self) { + let mut state = self.state.lock().expect("action channel mutex poisoned"); + state.closed = true; + state.subscriber = None; + state.buffer.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + use futures::executor::block_on; + + fn channel() -> ActionChannel { + ActionChannel::new("closed") + } + + #[test] + fn buffered_actions_are_drained_in_fifo_order() { + let channel = channel(); + channel.publish("first".to_string()).unwrap(); + channel.publish("second".to_string()).unwrap(); + + let mut items = channel.subscribe(); + assert_eq!(block_on(items.next()), Some(Ok("first".to_string()))); + assert_eq!(block_on(items.next()), Some(Ok("second".to_string()))); + } + + #[test] + fn full_startup_action_buffer_is_reported() { + let channel = channel(); + for index in 0..ACTION_BUFFER_CAPACITY { + channel.publish(index.to_string()).unwrap(); + } + + assert!(matches!( + channel.publish("overflow".to_string()), + Err(ProductRuntimeError::BufferFull) + )); + } + + #[test] + fn detach_keeps_buffered_actions_for_the_next_subscriber() { + let channel = channel(); + let mut first = channel.subscribe(); + channel.publish("live".to_string()).unwrap(); + assert_eq!(block_on(first.next()), Some(Ok("live".to_string()))); + + channel.detach(); + assert_eq!(block_on(first.next()), None); + channel.publish("buffered".to_string()).unwrap(); + + let mut second = channel.subscribe(); + assert_eq!(block_on(second.next()), Some(Ok("buffered".to_string()))); + } + + #[test] + fn closing_discards_buffered_actions() { + let channel = channel(); + channel.publish("discard me".to_string()).unwrap(); + channel.close(); + + // Subscribing to a closed channel interrupts, so a product can + // tell it from a stream that ran and finished. + let mut items = channel.subscribe(); + match block_on(items.next()) { + Some(Err(CallError::HostFailure { reason })) => assert_eq!(reason, "closed"), + other => panic!("a closed channel must interrupt, got {other:?}"), + } + assert!(matches!( + channel.publish("too late".to_string()), + Err(ProductRuntimeError::Closed) + )); + } + + #[test] + fn separate_connections_cannot_observe_each_others_actions() { + let first = channel(); + let second = channel(); + let mut first_items = first.subscribe(); + let mut second_items = second.subscribe(); + + first.publish("first only".to_string()).unwrap(); + second.publish("second only".to_string()).unwrap(); + assert_eq!( + block_on(first_items.next()), + Some(Ok("first only".to_string())) + ); + assert_eq!( + block_on(second_items.next()), + Some(Ok("second only".to_string())) + ); + + second.close(); + assert!(matches!( + second.publish("closed".to_string()), + Err(ProductRuntimeError::Closed) + )); + } +} diff --git a/rust/crates/truapi-server/src/runtime/chat.rs b/rust/crates/truapi-server/src/runtime/chat.rs index 8e5989d97..5bcbcd4d1 100644 --- a/rust/crates/truapi-server/src/runtime/chat.rs +++ b/rust/crates/truapi-server/src/runtime/chat.rs @@ -1,16 +1,8 @@ //! Connection-scoped Chat streams shared by product and native entrypoints. -use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; - -use futures::StreamExt; -use futures::channel::mpsc; -use truapi::latest::GenericError; -use truapi::versioned::chat::HostChatActionSubscribeItem; -use truapi::{CallError, Subscription}; +use std::sync::Arc; use crate::host_core::ProductRuntimeError; -const ACTION_BUFFER_CAPACITY: usize = 64; /// Chat access policy shared by the wire runtime and the native entrypoints: /// only a Chat-kind execution with an active session may use Chat, and the @@ -25,190 +17,3 @@ pub(crate) fn chat_platform_for( } chat.cloned().ok_or(ProductRuntimeError::Unsupported) } - -#[derive(Default)] -struct State { - actions: Option>, - action_buffer: VecDeque, - closed: bool, -} - -/// Mutable Chat protocol state owned by one product connection. -pub(crate) struct ChatConnection { - state: Arc>, -} - -impl ChatConnection { - /// Create empty Chat state for one product connection. - pub(crate) fn new() -> Self { - Self { - state: Arc::new(Mutex::new(State::default())), - } - } - - /// 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::interrupted(CallError::HostFailure { - reason: "chat is closed for this product connection".to_string(), - }); - } - for item in state.action_buffer.drain(..) { - let _ = sender.unbounded_send(item); - } - state.actions = Some(sender); - Subscription::new(receiver.map(Ok)) - } - - /// Publish one host-authored 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(()) - } - - /// End the current subscriber's stream while keeping buffered actions for - /// the next product connection that subscribes. - pub(crate) fn detach(&self) { - let mut state = self.state.lock().expect("chat state mutex poisoned"); - state.actions = None; - } - - /// Close all connection-scoped Chat streams and discard buffered work. - #[cfg(any(test, not(target_arch = "wasm32")))] - 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(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use futures::StreamExt; - use futures::executor::block_on; - use truapi::v01; - use truapi::v01::{ChatActionPayload, ChatMessageContent}; - - 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() - } - - #[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(Ok(action("first")))); - assert_eq!(block_on(actions.next()), Some(Ok(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 detach_keeps_buffered_actions_for_the_next_subscriber() { - let connection = connection(); - let mut first = connection.subscribe_actions(); - connection.publish_action(action("live")).unwrap(); - assert_eq!(block_on(first.next()), Some(Ok(action("live")))); - - connection.detach(); - assert_eq!(block_on(first.next()), None); - connection.publish_action(action("buffered")).unwrap(); - - let mut second = connection.subscribe_actions(); - assert_eq!(block_on(second.next()), Some(Ok(action("buffered")))); - } - - #[test] - fn closing_discards_buffered_actions() { - let connection = connection(); - connection.publish_action(action("discard me")).unwrap(); - connection.close(); - - // Subscribing to a closed connection interrupts, so a product can - // tell it from a stream that ran and finished. - let mut actions = connection.subscribe_actions(); - assert!(matches!( - block_on(actions.next()), - Some(Err(CallError::HostFailure { .. })) - )); - assert!(matches!( - connection.publish_action(action("too late")), - Err(ProductRuntimeError::Closed) - )); - } - - #[test] - fn separate_connections_cannot_observe_each_others_actions() { - 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(Ok(action("first only"))) - ); - assert_eq!( - block_on(second_actions.next()), - Some(Ok(action("second only"))) - ); - - second.close(); - assert!(matches!( - second.publish_action(action("closed")), - Err(ProductRuntimeError::Closed) - )); - } -} diff --git a/rust/crates/truapi-server/src/runtime/renderer.rs b/rust/crates/truapi-server/src/runtime/renderer.rs new file mode 100644 index 000000000..37cae0f4a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/renderer.rs @@ -0,0 +1,14 @@ +//! Connection-scoped Renderer policy shared by product and native entrypoints. + +use crate::host_core::ProductRuntimeError; + +/// Only a Worker execution draws bodies or receives their actions. No session +/// and no native adapter are required, so a signed-out host still renders. +pub(crate) fn renderer_access_for( + execution_kind: truapi_platform::ProductExecutionKind, +) -> Result<(), ProductRuntimeError> { + if execution_kind != truapi_platform::ProductExecutionKind::Worker { + return Err(ProductRuntimeError::Denied); + } + Ok(()) +} diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs index 34a577c85..4468c0e58 100644 --- a/rust/crates/truapi-server/src/subscription.rs +++ b/rust/crates/truapi-server/src/subscription.rs @@ -810,10 +810,10 @@ mod tests { // The fixture above recurses through `Box`, which uses a different // `Decode` impl than the `Vec` the production type recurses // through. Pin the boundary on the type actually decoded here. - fn nested(depth: u32) -> truapi::versioned::chat::ProductChatCustomMessageRenderItem { - let mut node = truapi::v01::CustomRendererNode::Nil; + fn nested(depth: u32) -> truapi::versioned::renderer::ProductRendererRenderItem { + let mut node = truapi::v01::RendererNode::Nil; for _ in 0..depth { - node = truapi::v01::CustomRendererNode::Box { + node = truapi::v01::RendererNode::Box { modifiers: Vec::new(), props: truapi::v01::BoxProps { content_alignment: None, @@ -821,13 +821,13 @@ mod tests { children: vec![node], }; } - truapi::versioned::chat::ProductChatCustomMessageRenderItem::V1(node) + truapi::versioned::renderer::ProductRendererRenderItem::V1(node) } let decode = |depth: u32| { let bytes = nested(depth).encode(); let mut input = &bytes[..]; - truapi::versioned::chat::ProductChatCustomMessageRenderItem::decode_with_depth_limit( + truapi::versioned::renderer::ProductRendererRenderItem::decode_with_depth_limit( MAX_SUBSCRIPTION_DECODE_DEPTH, &mut input, ) diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index dd8e2cc61..e09b2c374 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -1439,27 +1439,27 @@ impl WasmProductRuntime { Ok(()) } - /// Start the host-initiated render subscription for one stored custom Chat - /// message. `onUpdate` receives each replacement tree as a SCALE-encoded - /// `CustomRendererNode`. Exactly one terminal follows: `onComplete` when the - /// stream ended with the last tree standing, or `onError` when the product - /// could not serve the render and the last tree is partial. Rejects when - /// this connection may not reach Chat. - #[wasm_bindgen(js_name = renderCustomMessage)] - pub fn render_custom_message( + /// Start the host-initiated render subscription for one body. `request` is + /// a SCALE-encoded `ProductRendererRenderRequest`. `onUpdate` receives each + /// replacement tree as a SCALE-encoded `RendererNode`. Exactly one terminal + /// follows: `onComplete` when the stream ended with the last tree standing, + /// or `onError` when the product could not serve the render and the last + /// tree is partial. Rejects when this connection may not render. + #[wasm_bindgen(js_name = render)] + pub fn render( &self, - message_id: String, - message_type: String, - payload: Vec, + request: Vec, on_update: Function, on_complete: Function, on_error: Function, - ) -> Result { + ) -> Result { + let request = v01::ProductRendererRenderRequest::decode(&mut request.as_slice()) + .map_err(|err| JsValue::from_str(&format!("render request did not decode: {err}")))?; let mut stream = self .inner .core .control() - .render_custom_message(message_id, message_type, payload) + .render(request) .map_err(|err| JsValue::from_str(&err.to_string()))?; let on_update = SendWrapper::new(on_update); let on_complete = SendWrapper::new(on_complete); @@ -1487,7 +1487,7 @@ impl WasmProductRuntime { ) .await; }); - Ok(WasmCustomRendererSubscription { abort: Some(abort) }) + Ok(WasmRendererSubscription { abort: Some(abort) }) } /// Publish one host-authored Chat action into this connection's action @@ -1503,17 +1503,31 @@ impl WasmProductRuntime { .publish_chat_action(action) .map_err(|err| JsValue::from_str(&err.to_string())) } + + /// Publish one action triggered inside a product-rendered body, buffered + /// until the product subscribes. Takes a SCALE-encoded + /// `HostRendererActionSubscribeItem`. + #[wasm_bindgen(js_name = publishRendererAction)] + pub fn publish_renderer_action(&self, item: Vec) -> Result<(), JsValue> { + let item = v01::HostRendererActionSubscribeItem::decode(&mut item.as_slice()) + .map_err(|err| JsValue::from_str(&format!("renderer action did not decode: {err}")))?; + self.inner + .core + .control() + .publish_renderer_action(item) + .map_err(|err| JsValue::from_str(&err.to_string())) + } } -/// Cancellable observation of one custom-message render instance. Dropping the -/// handle on the JS side does not stop the stream; call `cancel`. +/// Cancellable observation of one render instance. Dropping the handle on the +/// JS side does not stop the stream; call `cancel`. #[wasm_bindgen] -pub struct WasmCustomRendererSubscription { +pub struct WasmRendererSubscription { abort: Option, } #[wasm_bindgen] -impl WasmCustomRendererSubscription { +impl WasmRendererSubscription { /// Stop delivering renderer updates. Idempotent. pub fn cancel(&mut self) { if let Some(abort) = self.abort.take() { diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index a4a7bb03f..4279ccadd 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -11,6 +11,7 @@ pub mod notifications; pub mod payment; pub mod permissions; pub mod preimage; +pub mod renderer; pub mod resource_allocation; pub mod signing; pub mod statement_store; @@ -28,6 +29,7 @@ pub use notifications::Notifications; pub use payment::Payment; pub use permissions::Permissions; pub use preimage::Preimage; +pub use renderer::Renderer; pub use resource_allocation::ResourceAllocation; pub use signing::Signing; pub use statement_store::StatementStore; @@ -47,6 +49,7 @@ pub trait TrUApi: + Payment + Permissions + Preimage + + Renderer + ResourceAllocation + Signing + StatementStore @@ -69,6 +72,7 @@ impl TrUApi for T where + Payment + Permissions + Preimage + + Renderer + ResourceAllocation + Signing + StatementStore diff --git a/rust/crates/truapi/src/api/chat.rs b/rust/crates/truapi/src/api/chat.rs index ca3bb6004..82092044b 100644 --- a/rust/crates/truapi/src/api/chat.rs +++ b/rust/crates/truapi/src/api/chat.rs @@ -5,8 +5,7 @@ use crate::versioned::chat::{ HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, - HostChatRegisterBotRequest, HostChatRegisterBotResponse, ProductChatCustomMessageRenderItem, - ProductChatCustomMessageRenderRequest, + HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; use crate::{CallContext, CallError, Subscription}; use crate::{wire, wire_trait}; @@ -123,19 +122,5 @@ pub trait Chat: Send + Sync { Subscription::interrupted(CallError::unavailable()) } - /// Streams renderer trees for one stored custom message. - /// - /// ```ts - /// truapi.chat.onCustomMessageRender(({ messageType, payload }, send) => { - /// send({ tag: "String", value: { text: `${messageType}: ${payload}` } }); - /// }); - /// ``` - #[wire(host_initiated, id = 5)] - fn custom_message_render( - &self, - _cx: &CallContext, - _request: ProductChatCustomMessageRenderRequest, - ) -> Subscription> { - Subscription::interrupted(CallError::unavailable()) - } + // Id 5 is spent and must never be reassigned; the next method takes 6. } diff --git a/rust/crates/truapi/src/api/renderer.rs b/rust/crates/truapi/src/api/renderer.rs new file mode 100644 index 000000000..3795a5ec4 --- /dev/null +++ b/rust/crates/truapi/src/api/renderer.rs @@ -0,0 +1,50 @@ +//! Unified [`Renderer`] trait. + +use crate::latest::GenericError; +use crate::versioned::renderer::{ + HostRendererActionSubscribeItem, ProductRendererRenderItem, ProductRendererRenderRequest, +}; +use crate::{CallContext, CallError, Subscription}; +use crate::{wire, wire_trait}; + +/// Product-rendered bodies and the actions triggered inside them. +#[wire_trait(id = 17)] +#[crate::service(required_execution = Worker)] +#[crate::async_trait] +pub trait Renderer: Send + Sync { + /// Streams renderer trees for one product-rendered body. Each item + /// replaces the previous tree. The stream stays open while the body is + /// displayed so the product can redraw in place. + /// + /// ```ts + /// truapi.renderer.onRender(({ context, payload }, send) => { + /// send({ tag: "String", value: { text: `${context.tag}: ${payload}` } }); + /// }); + /// ``` + #[wire(host_initiated, id = 0)] + fn render( + &self, + _cx: &CallContext, + _request: ProductRendererRenderRequest, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } + + /// Subscribe to actions triggered inside this product's rendered bodies. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const action = await firstValueFrom( + /// from(truapi.renderer.actionSubscribe()), + /// ); + /// console.log("action received:", action.context, action.actionId); + /// ``` + #[wire(id = 1)] + async fn action_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 383c49cf1..400970947 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -52,20 +52,24 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, AllocationOutcome, ChainIdentifier, ChatAction, + AccountId, AllocatableResource, AllocationOutcome, Arrangement, Background, BlendingMode, + BorderStyle, BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoomRegistrationStatus, - ContextualAlias, DerivationIndex, GenericError, HostAccountCreateProofRequest, + ColorToken, ColumnProps, ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, + Effect, EffectProps, GenericError, HorizontalAlignment, HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, HostAccountSignVrfError, HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData, - NotificationId, OperationStartedResult, ProductAccountId, ProductProofContext, RawPayload, - RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, - RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, - RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, - RingVrfKeyDisclosure, RingVrfPublicKey, RuntimeApi, RuntimeSpec, RuntimeType, - SignedStatement, Statement, StatementProof, StorageQueryItem, StorageQueryType, - StorageResultItem, ThemeName, ThemeVariant, TxPayloadExtension, VrfSignature, + ImageFit, ImageProps, ImageSource, Modifier, NotificationId, OperationStartedResult, + ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, + RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, + RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, + RingVrfKeyDisclosure, RingVrfPublicKey, RowProps, RuntimeApi, RuntimeSpec, RuntimeType, + Shape, SignedStatement, Size, Statement, StatementProof, StorageQueryItem, + StorageQueryType, StorageResultItem, TextFieldProps, TextProps, ThemeName, ThemeVariant, + TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, }; /// Latest payload type of a versioned envelope. @@ -96,12 +100,14 @@ pub mod latest { pub type HostChatPostMessageResponse = LatestOf; /// Native chat message posting failure. pub type HostChatPostMessageError = LatestOf; - /// Host-to-product custom render work request. - pub type ProductChatCustomMessageRenderRequest = - LatestOf; - /// Product-to-host custom renderer tree. - pub type ProductChatCustomMessageRenderItem = - LatestOf; + /// Action triggered inside a product-rendered body, delivered to the worker. + pub type HostRendererActionSubscribeItem = + LatestOf; + /// Host-to-product render request for one body. + pub type ProductRendererRenderRequest = + LatestOf; + /// Product-to-host renderer tree. + pub type ProductRendererRenderItem = LatestOf; /// Contextual alias derivation result. pub type HostAccountGetAliasResponse = LatestOf; diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index afb43cf7c..e9c970b68 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -12,6 +12,7 @@ mod notifications; mod payment; mod permissions; mod preimage; +mod renderer; mod resource_allocation; mod signing; mod statement_store; @@ -31,6 +32,7 @@ pub use notifications::*; pub use payment::*; pub use permissions::*; pub use preimage::*; +pub use renderer::*; pub use resource_allocation::*; pub use signing::*; pub use statement_store::*; diff --git a/rust/crates/truapi/src/v01/chat.rs b/rust/crates/truapi/src/v01/chat.rs index df983f1fe..87539342d 100644 --- a/rust/crates/truapi/src/v01/chat.rs +++ b/rust/crates/truapi/src/v01/chat.rs @@ -1,7 +1,3 @@ -/// UI tree types for host-rendered custom chat messages. -pub mod custom_renderer; -pub use custom_renderer::*; - use parity_scale_codec::{Decode, Encode}; /// Request to create a chat room. @@ -180,7 +176,9 @@ pub struct ChatReaction { pub emoji: String, } -/// A custom message with application-defined type and binary payload. +/// A custom message with application-defined type and binary payload. The +/// host draws it through `Renderer::render`, with a `ChatMessage` context +/// carrying `message_type` and `payload` as the render payload. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct ChatCustomMessage { @@ -242,7 +240,8 @@ pub enum HostChatPostMessageError { }, } -/// Payload when a user clicks an action button. +/// A press on a button the host draws for a `ChatMessageContent::Actions` +/// message. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct ActionTrigger { @@ -271,7 +270,7 @@ pub struct ChatCommand { pub enum ChatActionPayload { /// A peer posted a message. MessagePosted(ChatMessageContent), - /// A user triggered an action button. + /// A user pressed a host-drawn `Actions` button. ActionTriggered(ActionTrigger), /// A user issued a command. Command(ChatCommand), diff --git a/rust/crates/truapi/src/v01/chat/custom_renderer.rs b/rust/crates/truapi/src/v01/chat/custom_renderer.rs deleted file mode 100644 index ad0d1ed6c..000000000 --- a/rust/crates/truapi/src/v01/chat/custom_renderer.rs +++ /dev/null @@ -1,471 +0,0 @@ -use parity_scale_codec::{Compact, Decode, Encode, OptionBool}; - -/// A size/dimension value (logical pixels) used across the custom renderer. -/// -/// Encoded as a SCALE `Compact`: the common small values cost a single -/// byte on the wire instead of eight. -pub type Size = Compact; - -#[cfg(feature = "uniffi")] -uniffi::custom_type!(Size, u64, { - remote, - lower: |size| size.0, - try_lift: |size| Ok(Compact(size)), -}); - -/// An optional boolean with the compact SCALE encoding used by renderer props. -pub type OptionalBool = OptionBool; - -#[cfg(feature = "uniffi")] -uniffi::custom_type!(OptionalBool, Option, { - remote, - lower: |value| value.0, - try_lift: |value| Ok(OptionBool(value)), -}); - -/// CSS-like dimensions: (top, end, bottom, start). -/// Bottom defaults to top, start defaults to end when `None`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct Dimensions { - /// Top dimension. - pub top: Size, - /// End dimension. - pub end: Size, - /// Bottom dimension. Defaults to top when absent. - pub bottom: Option, - /// Start dimension. Defaults to end when absent. - pub start: Option, -} - -/// Text typography presets. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum TypographyStyle { - /// Large headline text. - HeadlineLarge, - /// Medium title text, regular weight. - TitleMediumRegular, - /// Large body text, regular weight. - BodyLargeRegular, - /// Medium body text, regular weight. - BodyMediumRegular, - /// Small body text, regular weight. - BodySmallRegular, -} - -/// Button style variants. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum ButtonVariant { - /// Emphasized button for the primary action. - Primary, - /// De-emphasized button for secondary actions. - Secondary, - /// Text-only button without a background. - Text, -} - -/// Semantic color tokens for theming. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum ColorToken { - /// Primary foreground (text) color. - FgPrimary, - /// Secondary foreground color. - FgSecondary, - /// Tertiary foreground color. - FgTertiary, - /// Main surface background. - BgSurfaceMain, - /// Container surface background. - BgSurfaceContainer, - /// Nested surface background. - BgSurfaceNested, - /// Foreground color for success states. - FgSuccess, - /// Foreground color for error states. - FgError, - /// Foreground color for warning states. - FgWarning, -} - -/// 2D content alignment. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum ContentAlignment { - /// Top edge, start side. - TopStart, - /// Top edge, horizontally centered. - TopCenter, - /// Top edge, end side. - TopEnd, - /// Vertically centered, start side. - CenterStart, - /// Centered on both axes. - Center, - /// Vertically centered, end side. - CenterEnd, - /// Bottom edge, start side. - BottomStart, - /// Bottom edge, horizontally centered. - BottomCenter, - /// Bottom edge, end side. - BottomEnd, -} - -/// Horizontal alignment options. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum HorizontalAlignment { - /// Align to the start edge. - Start, - /// Center horizontally. - Center, - /// Align to the end edge. - End, -} - -/// Vertical alignment options. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum VerticalAlignment { - /// Align to the top. - Top, - /// Center vertically. - Center, - /// Align to the bottom. - Bottom, -} - -/// Layout arrangement (like CSS flexbox `justify-content`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum Arrangement { - /// Pack children at the start. - Start, - /// Pack children at the end. - End, - /// Pack children in the center. - Center, - /// Distribute with space between children. - SpaceBetween, - /// Distribute with space around each child. - SpaceAround, - /// Distribute with equal space between and around children. - SpaceEvenly, -} - -/// Shape for borders and backgrounds. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum Shape { - /// Border radius value. - Rounded { - /// Border radius. - radius: Size, - }, - /// Circular shape. - Circle, -} - -/// Border styling. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct BorderStyle { - /// Border width. - pub width: Size, - /// Border color. - pub color: ColorToken, - /// Border shape. - pub shape: Option, -} - -/// Background styling. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct Background { - /// Background color. - pub color: ColorToken, - /// Background shape. - pub shape: Option, -} - -/// Layout and styling modifiers applied to custom renderer components. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum Modifier { - /// Outer spacing. - Margin(Dimensions), - /// Inner spacing. - Padding(Dimensions), - /// Background fill. - Background(Background), - /// Border style. - Border(BorderStyle), - /// Fixed height. - Height { - /// Fixed height. - height: Size, - }, - /// Fixed width. - Width { - /// Fixed width. - width: Size, - }, - /// Minimum width. - MinWidth { - /// Minimum width. - width: Size, - }, - /// Minimum height. - MinHeight { - /// Minimum height. - height: Size, - }, - /// Fill available width. - FillWidth { - /// Whether width should fill available space. - enabled: bool, - }, - /// Fill available height. - FillHeight { - /// Whether height should fill available space. - enabled: bool, - }, -} - -/// Properties for a [`CustomRendererNode::Box`] container. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct BoxProps { - /// Content alignment within the box. - pub content_alignment: Option, -} - -/// Properties for a [`CustomRendererNode::Column`] layout. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct ColumnProps { - /// Horizontal alignment of children. - pub horizontal_alignment: Option, - /// Vertical arrangement of children. - pub vertical_arrangement: Option, -} - -/// Properties for a [`CustomRendererNode::Row`] layout. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct RowProps { - /// Vertical alignment of children. - pub vertical_alignment: Option, - /// Horizontal arrangement of children. - pub horizontal_arrangement: Option, -} - -/// Properties for a [`CustomRendererNode::Text`] display. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct TextProps { - /// Typography preset. - pub style: Option, - /// Text color. - pub color: Option, -} - -/// Properties for a [`CustomRendererNode::Button`]. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct ButtonProps { - /// Button label text. - pub text: String, - /// Button style variant. - pub variant: Option, - /// Whether the button is enabled. Absent leaves the default to the host. - pub enabled: OptionalBool, - /// Whether the button shows a loading state. Absent leaves the default to the host. - pub loading: OptionalBool, - /// Action identifier triggered on click. - pub click_action: Option, -} - -/// Properties for a [`CustomRendererNode::TextField`]. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct TextFieldProps { - /// Current text value. - pub text: String, - /// Placeholder text. - pub placeholder: Option, - /// Field label. - pub label: Option, - /// Whether the field is enabled. Absent leaves the default to the host. - pub enabled: OptionalBool, - /// Action identifier triggered when the value changes. - pub value_change_action: Option, -} - -/// A node in the custom renderer UI tree. Component variants contain recursive -/// `children` fields. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum CustomRendererNode { - /// Empty node. - Nil, - /// Raw text string. - String { - /// Raw text. - text: String, - }, - /// Generic container. - Box { - /// Layout and styling modifiers. - modifiers: Vec, - /// Box properties. - props: BoxProps, - /// Child nodes. - children: Vec, - }, - /// Vertical layout. - Column { - /// Layout and styling modifiers. - modifiers: Vec, - /// Column properties. - props: ColumnProps, - /// Child nodes. - children: Vec, - }, - /// Horizontal layout. - Row { - /// Layout and styling modifiers. - modifiers: Vec, - /// Row properties. - props: RowProps, - /// Child nodes. - children: Vec, - }, - /// Flexible space. - Spacer { - /// Layout and styling modifiers. - modifiers: Vec, - /// Child nodes. - children: Vec, - }, - /// Text display. - Text { - /// Layout and styling modifiers. - modifiers: Vec, - /// Text properties. - props: TextProps, - /// Child nodes. - children: Vec, - }, - /// Interactive button. - Button { - /// Layout and styling modifiers. - modifiers: Vec, - /// Button properties. - props: ButtonProps, - /// Child nodes. - children: Vec, - }, - /// Text input. - TextField { - /// Layout and styling modifiers. - modifiers: Vec, - /// Text-field properties. - props: TextFieldProps, - /// Child nodes. - children: Vec, - }, -} - -/// Render work sent by the host when a native custom-message cell appears. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ProductChatCustomMessageRenderRequest { - /// Stable identifier used to correlate 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, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Encode)] - struct RendererWireComponent

{ - modifiers: Vec, - props: P, - children: Vec, - } - - #[derive(Encode)] - enum RendererWireNode

{ - #[codec(index = 3)] - Column(RendererWireComponent

), - } - - fn renderer_node() -> CustomRendererNode { - CustomRendererNode::Column { - modifiers: vec![Modifier::Padding(Dimensions { - top: Compact(12), - end: Compact(8), - bottom: None, - start: Some(Compact(4)), - })], - props: ColumnProps { - horizontal_alignment: Some(HorizontalAlignment::Center), - vertical_arrangement: Some(Arrangement::SpaceBetween), - }, - children: vec![ - CustomRendererNode::String { - text: "Votes: 1".to_string(), - }, - CustomRendererNode::Button { - modifiers: Vec::new(), - props: ButtonProps { - text: "Vote".to_string(), - variant: Some(ButtonVariant::Primary), - enabled: OptionBool(Some(true)), - loading: OptionBool(None), - click_action: Some("vote".to_string()), - }, - children: Vec::new(), - }, - ], - } - } - - #[test] - fn renderer_node_preserves_the_component_wire_shape() { - let node = renderer_node(); - let CustomRendererNode::Column { - modifiers, - props, - children, - } = node.clone() - else { - unreachable!(); - }; - let wire = RendererWireNode::Column(RendererWireComponent { - modifiers, - props, - children, - }); - - assert_eq!(node.encode(), wire.encode()); - } - - #[cfg(feature = "uniffi")] - #[test] - fn renderer_node_round_trips_through_uniffi() { - let node = renderer_node(); - let ffi = >::lower(node.clone()); - let lifted = >::try_lift(ffi).unwrap(); - - assert_eq!(lifted, node); - } -} diff --git a/rust/crates/truapi/src/v01/renderer.rs b/rust/crates/truapi/src/v01/renderer.rs new file mode 100644 index 000000000..1676e1f9f --- /dev/null +++ b/rust/crates/truapi/src/v01/renderer.rs @@ -0,0 +1,634 @@ +//! Product-rendered body trees and the contexts that name them. + +use parity_scale_codec::{Compact, Decode, Encode, OptionBool}; + +/// A size in logical pixels, SCALE-encoded as `Compact`. +pub type Size = Compact; + +#[cfg(feature = "uniffi")] +uniffi::custom_type!(Size, u64, { + remote, + lower: |size| size.0, + try_lift: |size| Ok(Compact(size)), +}); + +#[cfg(feature = "uniffi")] +uniffi::custom_type!(OptionBool, Option, { + remote, + lower: |value| value.0, + try_lift: |value| Ok(OptionBool(value)), +}); + +/// Edge dimensions. `bottom` defaults to `top` and `start` to `end` when absent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct Dimensions { + /// Top edge. + pub top: Size, + /// End edge. + pub end: Size, + /// Bottom edge; defaults to `top`. + pub bottom: Option, + /// Start edge; defaults to `end`. + pub start: Option, +} + +/// Typography presets, resolved by the host's design system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum TypographyStyle { + /// Large headline text. + HeadlineLarge, + /// Medium title text, regular weight. + TitleMediumRegular, + /// Large body text, regular weight. + BodyLargeRegular, + /// Medium body text, regular weight. + BodyMediumRegular, + /// Small body text, regular weight. + BodySmallRegular, +} + +/// Button emphasis. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ButtonVariant { + /// Emphasized button for the primary action. + Primary, + /// De-emphasized button for secondary actions. + Secondary, + /// Text-only button without a background. + Text, +} + +/// Semantic color tokens, resolved by the host's theme. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ColorToken { + /// Primary foreground. + FgPrimary, + /// Secondary foreground. + FgSecondary, + /// Tertiary foreground. + FgTertiary, + /// Main surface background. + BgSurfaceMain, + /// Container surface background. + BgSurfaceContainer, + /// Nested surface background. + BgSurfaceNested, + /// Foreground for success states. + FgSuccess, + /// Foreground for error states. + FgError, + /// Foreground for warning states. + FgWarning, +} + +/// Placement of content within a `Box`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ContentAlignment { + /// Top edge, start side. + TopStart, + /// Top edge, horizontally centered. + TopCenter, + /// Top edge, end side. + TopEnd, + /// Vertically centered, start side. + CenterStart, + /// Centered on both axes. + Center, + /// Vertically centered, end side. + CenterEnd, + /// Bottom edge, start side. + BottomStart, + /// Bottom edge, horizontally centered. + BottomCenter, + /// Bottom edge, end side. + BottomEnd, +} + +/// Cross-axis alignment of `Column` children. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HorizontalAlignment { + /// Align to the start edge. + Start, + /// Center horizontally. + Center, + /// Align to the end edge. + End, +} + +/// Cross-axis alignment of `Row` children. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum VerticalAlignment { + /// Align to the top. + Top, + /// Center vertically. + Center, + /// Align to the bottom. + Bottom, +} + +/// Main-axis distribution of children. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum Arrangement { + /// Pack children at the start. + Start, + /// Pack children at the end. + End, + /// Pack children in the center. + Center, + /// Distribute with space between children. + SpaceBetween, + /// Distribute with space around each child. + SpaceAround, + /// Distribute with equal space between and around children. + SpaceEvenly, +} + +/// Outline of a background or border. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum Shape { + /// Rounded corners with the given radius. + Rounded(Size), + /// Circular shape. + Circle, + /// Square corners. + Square, +} + +/// Border styling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct BorderStyle { + /// Border width. + pub width: Size, + /// Border color. + pub color: ColorToken, + /// Border shape. + pub shape: Option, +} + +/// Background styling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct Background { + /// Background color. + pub color: ColorToken, + /// Background shape. + pub shape: Option, +} + +/// How a node composites with what is behind it. The values are those common +/// to CSS `mix-blend-mode`, SwiftUI `BlendMode` and Compose `BlendMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum BlendingMode { + /// Source over destination. + Normal, + /// Multiplies source and destination. + Multiply, + /// Inverse multiply. + Screen, + /// Multiply or screen depending on the destination. + Overlay, + /// Darker of source and destination. + Darken, + /// Lighter of source and destination. + Lighten, + /// Brightens the destination to reflect the source. + ColorDodge, + /// Darkens the destination to reflect the source. + ColorBurn, + /// Multiply or screen depending on the source. + HardLight, + /// Darken or lighten depending on the source. + SoftLight, + /// Absolute difference. + Difference, + /// Difference with lower contrast. + Exclusion, + /// Source hue with destination saturation and luminosity. + Hue, + /// Source saturation with destination hue and luminosity. + Saturation, + /// Source hue and saturation with destination luminosity. + Color, + /// Source luminosity with destination hue and saturation. + Luminosity, +} + +/// Layout and styling applied to one node. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum Modifier { + /// Outer spacing. + Margin(Dimensions), + /// Inner spacing. + Padding(Dimensions), + /// Background fill. + Background(Background), + /// Border. + Border(BorderStyle), + /// Fixed height. + Height(Size), + /// Fixed width. + Width(Size), + /// Minimum width. + MinWidth(Size), + /// Minimum height. + MinHeight(Size), + /// Fill the available width. + FillWidth(bool), + /// Fill the available height. + FillHeight(bool), + /// 0 is transparent, 255 is opaque. + Opacity(u8), + /// Compositing mode against what is behind the node. + BlendingMode(BlendingMode), +} + +/// Properties of a `Box`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct BoxProps { + /// Placement of content within the box. + pub content_alignment: Option, +} + +/// Properties of a `Column`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ColumnProps { + /// Cross-axis alignment of children. + pub horizontal_alignment: Option, + /// Main-axis distribution of children. + pub vertical_arrangement: Option, +} + +/// Properties of a `Row`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct RowProps { + /// Cross-axis alignment of children. + pub vertical_alignment: Option, + /// Main-axis distribution of children. + pub horizontal_arrangement: Option, +} + +/// Properties of a `Text`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct TextProps { + /// Typography preset. + pub style: Option, + /// Text color. + pub color: Option, +} + +/// Properties of a `Button`. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ButtonProps { + /// Button label. + pub text: String, + /// Button emphasis. + pub variant: Option, + /// Whether the button accepts presses. Absent leaves the default to the host. + pub enabled: OptionBool, + /// Whether the button shows a loading state. A loading button accepts no + /// presses. Absent leaves the default to the host. + pub loading: OptionBool, + /// Action triggered on press. A button without one is inert. + pub click_action: Option, +} + +/// Where image bytes come from. The host fetches them; the tree carries no URL. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ImageSource { + /// A Bulletin chain blob, addressed by its CID. + Bulletin(String), + /// A file inside the product's executable archive, as a path relative to + /// the archive root. + Archive(String), +} + +/// How an image meets the box its modifiers size. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ImageFit { + /// The image is not resized. + None, + /// Resized to fill the container without preserving the aspect ratio. + Fill, + /// Preserves the aspect ratio and fills the container, cutting overflow. + Cover, + /// Preserves the aspect ratio and fits inside the container, leaving empty + /// space if needed. + Contain, + /// Whichever of `None` or `Contain` yields the smaller image. + ScaleDown, +} + +/// Properties of an `Image`. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ImageProps { + /// Where the image bytes come from. + pub source: ImageSource, + /// Defaults to `Fill`. + pub fit: Option, +} + +/// A visual effect. Each variant names one effect and carries its parameters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum Effect { + /// Animated rainbow tint over the children. + Rainbow, +} + +/// Properties of an `Effect`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct EffectProps { + /// The effect applied to the children. + pub effect: Effect, +} + +/// Properties of a `TextField`. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct TextFieldProps { + /// Current value. + pub text: String, + /// Shown when the value is empty. + pub placeholder: Option, + /// Field label. + pub label: Option, + /// Whether the field accepts input. Absent leaves the default to the host. + pub enabled: OptionBool, + /// Action triggered on every value change. The action carries the new + /// value as UTF-8 bytes, with no length prefix. + pub value_change_action: Option, +} + +/// A node in a product-rendered tree. Container variants recurse through +/// `children`. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum RendererNode { + /// Draws nothing. + Nil, + /// A text run. + String { + /// Raw text. + text: String, + }, + /// Generic container. + Box { + /// Layout and styling. + modifiers: Vec, + /// Box properties. + props: BoxProps, + /// Child nodes. + children: Vec, + }, + /// Vertical layout. + Column { + /// Layout and styling. + modifiers: Vec, + /// Column properties. + props: ColumnProps, + /// Child nodes. + children: Vec, + }, + /// Horizontal layout. + Row { + /// Layout and styling. + modifiers: Vec, + /// Row properties. + props: RowProps, + /// Child nodes. + children: Vec, + }, + /// Flexible space. + Spacer { + /// Layout and styling. + modifiers: Vec, + }, + /// Styled text. + Text { + /// Layout and styling. + modifiers: Vec, + /// Text properties. + props: TextProps, + /// Child nodes. + children: Vec, + }, + /// Interactive button. + Button { + /// Layout and styling. + modifiers: Vec, + /// Button properties. + props: ButtonProps, + /// Child nodes. + children: Vec, + }, + /// Single-line text input. + TextField { + /// Layout and styling. + modifiers: Vec, + /// Text-field properties. + props: TextFieldProps, + }, + /// Image, sized by modifiers. + Image { + /// Layout and styling. + modifiers: Vec, + /// Image properties. + props: ImageProps, + }, + /// Applies its effect to its children. + Effect { + /// Effect properties. + props: EffectProps, + /// Child nodes. + children: Vec, + }, +} + +/// Where a product-rendered body lives, and the id that names it there. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum RenderContext { + /// A message in a chat room. + ChatMessage { + /// Room the message was posted in. + room_id: String, + /// Message id, as returned by `Chat::post_message`. + message_id: String, + /// Product-defined discriminator, as stored in + /// `ChatCustomMessage::message_type`. + message_type: String, + }, + /// A candidate answered to an input query. + InputWidget { + /// Candidate id, as the product answered it. + candidate_id: String, + }, + /// A card face in the host's Pocket collection. + PocketCard { + /// Card id, as declared in the product's worker manifest. + card_id: String, + }, +} + +/// A body the host needs drawn. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ProductRendererRenderRequest { + /// Where the body lives. + pub context: RenderContext, + /// Product-defined payload, opaque to the host. + pub payload: Vec, +} + +/// An action triggered inside a product-rendered body. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostRendererActionSubscribeItem { + /// Where the body lives. + pub context: RenderContext, + /// Which action was triggered, as named in the renderer tree. + pub action_id: String, + /// Data the node attached to the action. A `Button` press carries an + /// empty payload; a `TextField` value change carries the UTF-8 bytes of + /// the new value, with no length prefix. + pub payload: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Encode)] + struct RendererWireComponent

{ + modifiers: Vec, + props: P, + children: Vec, + } + + #[derive(Encode)] + enum RendererWireNode

{ + #[codec(index = 3)] + Column(RendererWireComponent

), + } + + fn renderer_node() -> RendererNode { + RendererNode::Column { + modifiers: vec![Modifier::Padding(Dimensions { + top: Compact(12), + end: Compact(8), + bottom: None, + start: Some(Compact(4)), + })], + props: ColumnProps { + horizontal_alignment: Some(HorizontalAlignment::Center), + vertical_arrangement: Some(Arrangement::SpaceBetween), + }, + children: vec![ + RendererNode::String { + text: "Votes: 1".to_string(), + }, + RendererNode::Button { + modifiers: Vec::new(), + props: ButtonProps { + text: "Vote".to_string(), + variant: Some(ButtonVariant::Primary), + enabled: OptionBool(Some(true)), + loading: OptionBool(None), + click_action: Some("vote".to_string()), + }, + children: Vec::new(), + }, + RendererNode::Spacer { + modifiers: vec![Modifier::Opacity(128)], + }, + RendererNode::Image { + modifiers: vec![Modifier::Width(Compact(40))], + props: ImageProps { + source: ImageSource::Bulletin("bafy".to_string()), + fit: Some(ImageFit::Cover), + }, + }, + RendererNode::Effect { + props: EffectProps { + effect: Effect::Rainbow, + }, + children: vec![RendererNode::Nil], + }, + ], + } + } + + #[test] + fn column_preserves_the_component_wire_shape() { + let node = renderer_node(); + let RendererNode::Column { + modifiers, + props, + children, + } = node.clone() + else { + unreachable!(); + }; + let wire = RendererWireNode::Column(RendererWireComponent { + modifiers, + props, + children, + }); + + assert_eq!(node.encode(), wire.encode()); + } + + #[test] + fn variants_encode_only_the_fields_they_carry() { + let spacer = RendererNode::Spacer { + modifiers: Vec::new(), + }; + assert_eq!(spacer.encode(), vec![5, 0]); + + let effect = RendererNode::Effect { + props: EffectProps { + effect: Effect::Rainbow, + }, + children: Vec::new(), + }; + assert_eq!(effect.encode(), vec![10, 0, 0]); + } + + #[test] + fn tree_round_trips_through_scale() { + let node = renderer_node(); + let decoded = RendererNode::decode(&mut node.encode().as_slice()).unwrap(); + assert_eq!(decoded, node); + } + + #[cfg(feature = "uniffi")] + #[test] + fn tree_round_trips_through_uniffi() { + let node = renderer_node(); + let ffi = >::lower(node.clone()); + let lifted = >::try_lift(ffi).unwrap(); + + assert_eq!(lifted, node); + } +} diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 36213f6c9..6950c8346 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -45,6 +45,7 @@ pub mod notifications; pub mod payment; pub mod permissions; pub mod preimage; +pub mod renderer; pub mod resource_allocation; pub mod signing; pub mod statement_store; diff --git a/rust/crates/truapi/src/versioned/chat.rs b/rust/crates/truapi/src/versioned/chat.rs index 562a9bae7..6c2d7356d 100644 --- a/rust/crates/truapi/src/versioned/chat.rs +++ b/rust/crates/truapi/src/versioned/chat.rs @@ -14,8 +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 ProductChatCustomMessageRenderRequest { V1 => v01::ProductChatCustomMessageRenderRequest } - pub enum ProductChatCustomMessageRenderItem { V1 => v01::CustomRendererNode } } #[cfg(test)] @@ -63,28 +61,4 @@ mod tests { new ); } - - #[test] - fn custom_render_start_matches_legacy_wire_fixture() { - let request = - ProductChatCustomMessageRenderRequest::V1(v01::ProductChatCustomMessageRenderRequest { - message_id: "message-1".into(), - message_type: "vote".into(), - payload: vec![1, 2], - }); - - assert_eq!( - hex::encode(request.encode()), - "00246d6573736167652d3110766f7465080102" - ); - } - - #[test] - fn custom_render_receive_matches_legacy_wire_fixture() { - let item = ProductChatCustomMessageRenderItem::V1(v01::CustomRendererNode::String { - text: "Votes: 1".into(), - }); - - assert_eq!(hex::encode(item.encode()), "000120566f7465733a2031"); - } } diff --git a/rust/crates/truapi/src/versioned/renderer.rs b/rust/crates/truapi/src/versioned/renderer.rs new file mode 100644 index 000000000..76d47e020 --- /dev/null +++ b/rust/crates/truapi/src/versioned/renderer.rs @@ -0,0 +1,64 @@ +//! Versioned wrappers for [`Renderer`](crate::api::Renderer) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum ProductRendererRenderRequest { V1 => v01::ProductRendererRenderRequest } + pub enum ProductRendererRenderItem { V1 => v01::RendererNode } + pub enum HostRendererActionSubscribeItem { V1 => v01::HostRendererActionSubscribeItem } +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::{Decode, Encode}; + + #[test] + fn render_item_string_matches_the_wire_fixture() { + let item = ProductRendererRenderItem::V1(v01::RendererNode::String { + text: "Votes: 1".into(), + }); + assert_eq!(hex::encode(item.encode()), "000120566f7465733a2031"); + } + + #[test] + fn render_request_carries_the_chat_context() { + let request = ProductRendererRenderRequest::V1(v01::ProductRendererRenderRequest { + context: v01::RenderContext::ChatMessage { + room_id: "room".into(), + message_id: "message-1".into(), + message_type: "vote".into(), + }, + payload: vec![1, 2], + }); + let bytes = request.encode(); + // V1 envelope, ChatMessage variant, then the three strings and payload. + assert_eq!( + hex::encode(&bytes), + "000010726f6f6d246d6573736167652d3110766f7465080102" + ); + assert_eq!( + ProductRendererRenderRequest::decode(&mut bytes.as_slice()).unwrap(), + request + ); + } + + #[test] + fn action_item_button_payload_is_empty() { + let item = HostRendererActionSubscribeItem::V1(v01::HostRendererActionSubscribeItem { + context: v01::RenderContext::PocketCard { + card_id: "loyalty".into(), + }, + action_id: "vote".into(), + payload: Vec::new(), + }); + let bytes = item.encode(); + // V1 envelope, PocketCard variant, the card and action ids, then the + // empty payload's length prefix. + assert_eq!(hex::encode(&bytes), "00021c6c6f79616c747910766f746500"); + assert_eq!( + HostRendererActionSubscribeItem::decode(&mut bytes.as_slice()).unwrap(), + item + ); + } +}