diff --git a/.changeset/pocket-modality.md b/.changeset/pocket-modality.md new file mode 100644 index 000000000..d81e51bae --- /dev/null +++ b/.changeset/pocket-modality.md @@ -0,0 +1,6 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Add the `pocket` service: `listSubscribe`, `removeCard`, `actionSubscribe`, and the host-initiated `onCardRender` face stream. diff --git a/docs/rfcs/pocket-modality.md b/docs/rfcs/pocket-modality.md new file mode 100644 index 000000000..a13f04e9f --- /dev/null +++ b/docs/rfcs/pocket-modality.md @@ -0,0 +1,202 @@ +--- +title: "Pocket modality" +owner: "Valentin Fernandez" +status: draft +--- + +# RFC — Pocket modality + +| | | +| --------------- | ----------------------------------------------------------------------------------------------- | +| **Start Date** | 2026-09-03 | +| **Description** | A host-owned collection of product-backed cards: how a card is added, rendered, opened, removed | +| **Authors** | Valentin Fernandez | + +## Summary + +Pocket is a host surface holding a small set of **cards**, each backed by a product. The host owns the collection and renders every collapsed card natively from a renderer tree the product's worker streams to it. Tapping a card opens the product's Widget executable. A product cannot add a card by itself: a card enters Pocket when the user follows a Pocket-targeted deeplink and approves a host dialog showing the card as it will look. Both the user and the owning product can remove a card. Three privileged cards, Humanity, Balance and Scarcity, are always present and removable by neither. + +Chat and Pocket are served by the product's single Worker executable, whose lifetime is a reference count: one reference per active chat, one per visible card, terminated at zero. + +The protocol change is one `Pocket` trait with four methods, a Pocket section in the Worker manifest, a deeplink grammar that names a target modality, and moving the renderer node types out of `chat`. Host-initiated calls already exist (`Chat::custom_message_render`), so no code-generation work is needed. + +Tracking issue: [#563](https://github.com/paritytech/host-rust-core/issues/563). + +## Motivation + +The iOS host already shows Humanity, Balance and Scarcity as cards, with the content and interactions of each card hard-coded into the host. Personhood is becoming a product ([RFC 0024](0024-personhood-as-product.md)), which declares `includes: { pocket: true }` and expects a card, and there is no contract behind that flag: nothing says how a product supplies a card's face, learns that the user tapped it, or is kept alive while the card is on screen. + +Without a contract each host invents one. The ordering constraint is that the first iteration, three known cards, ships before anyone publishes a card, so the rendering and lifecycle rules must stand on their own and the add flow must layer on top without changing them. + +## Approach + +### Model + +- A **card** is identified by `(product_id, card_id)`. `card_id` is a lowercase label the product declares. +- The **face** is the collapsed presentation. The host renders it natively from a `CustomRendererNode` tree, the same component set chat custom messages use, and keeps the last tree per card so the face is shown offline and at cold start before the worker answers. +- The **expanded card** is the product's `widget` executable in a WebView. +- The **worker** is the product's one Worker executable. It streams faces, receives actions, and is the only execution the `Pocket` trait is available to. +- A **privileged card** is one the host itself places and pins: present on first run without approval, never removable. Iteration 1 ships exactly three, Humanity, Balance and Scarcity, and the host designates the product that backs each (the personhood provider of RFC 0024 for Humanity). + +The host is the only writer of the collection. A product observes its own cards and may remove them, and nothing else. + +### Rendering and actions + +The host opens one `card_render` subscription per card whose face is on screen, passing the `card_id`. Every item is a complete tree that replaces the previous face. The host caches the newest tree durably; a subscription that ends with the worker gone leaves the cached face in place. + +Interactive nodes in the tree (`Button.click_action`, `TextField.value_change_action`) fire on the worker's `action_subscribe` stream with the `card_id` they came from, so one handler serves every card of the product. + +The renderer types (`CustomRendererNode`, its props, modifiers, tokens) move from `truapi::v01::chat::custom_renderer` to their own `truapi::v01::renderer` module and are re-exported through `truapi::latest`. Chat keeps using them. The wire encoding does not change; only the Rust path does. The approval dialog, the Pocket face, and a product's own web preview then all consume one type, and the product-sdk can ship a React reconciler for it in the style of `product-react-renderer`. + +### Expanded card + +Tapping a face opens the product's `widget` executable with the card named in the launch URL query, `card=`. The WebView talks to the worker through `call_worker` ([RFC 0027](https://github.com/paritytech/host-rust-core/pull/468)); Pocket adds no channel of its own. The Widget runs under the same product identity and storage namespace as the worker, so the card the user opened and the state it shows are one product's. Keeping the native face visible through the open and close animation, and preloading the WebView, are host implementation and not part of the contract. + +### Lifecycle + +Each product has one Worker executable, shared by Chat and Pocket, as the manifest already requires. The host keeps a reference count on it: + +- **+1** for each active chat surface the product serves, held while the surface is open. +- **+1** for each card whose face is on screen, held by the open `card_render` subscription. +- A host that honours RFC 0024's `includes.onLoad` holds **one permanent reference** of its own. That is what "global lifetime" means under this model, so the flag and the count compose. + +At zero the host terminates the executable after a short host-chosen grace period. The product cannot add a reference; it can only drop one, by removing a card. + +**Start budget.** When the count goes from zero to one the host instantiates the worker and evaluates its entry module under a bounded budget of wall-clock time and memory, host-configured and in the order of seconds. Module evaluation is the start hook: it is where the product registers its `card_render` and `action_subscribe` handlers. A handler still unregistered when the budget ends is unsupported for this run, and the host shows the cached face. Issue #563 calls this budget `onLoad`; the RFC avoids the name because RFC 0024 uses it for a manifest flag with a different meaning. + +### Adding a card (full iteration) + +Products **publish** card definitions in the Worker manifest ([Product Manifest Format](product-manifest.md)), alongside the existing `includes`: + +```ts +type WorkerManifest = CommonExecutableFields & { + kind: 'worker'; + entrypoint: string; + includes: { pocket?: boolean; chat?: boolean; input?: boolean }; + /** Cards the product can back. Absent unless `includes.pocket` is true. */ + pocket?: { cards: PocketCardDefinition[] }; +}; + +type PocketCardDefinition = { + id: string; // Lowercase label, unique within the product. + title: string; // Shown in the approval dialog and in host chrome. + preview: string; // Path inside the worker archive to a CustomRendererNode tree, JSON in the generated TypeScript shape. +}; +``` + +The preview is a static file in the CID-pinned archive, so the host can show a card before any product code runs, the same property the [funding modality](https://github.com/paritytech/host-rust-core/pull/339) relies on for its rail list. It is the face the user approves; the live face may differ once the worker streams. + +**Deeplinks name a modality.** A product URL is `polkadot://./` and today always opens the App. The first path segment `-` is reserved for host-handled targets and cannot be an App route: + +```text +polkadot://./ App, unchanged +polkadot://./-/pocket/add?card= Offer to add a published card +polkadot://./-/pocket/open?card= Expand a card that is present +``` + +A host without the named modality, or one that does not know the action, opens the App instead. Products reach a deeplink from their own web UI through `system.navigate_to`, which already lets `polkadot:` through without a grant, so an "Add to Pocket" button is one call. + +```mermaid +sequenceDiagram + participant U as User + participant H as Host + participant W as Product worker + + U->>H: polkadot://game.dot/-/pocket/add?card=loyalty + H->>H: resolve worker manifest, find card `loyalty`, fetch preview from archive + H->>U: dialog: title + rendered preview + Add + U->>H: Add + H->>H: insert card, refcount 0 → 1 + H->>W: start (start budget) + H->>W: card_render { card_id: "loyalty" } + W-->>H: face tree, and again on every change +``` + +An added card is an ordinary card from then on: same face stream, actions, expansion, removal rules, and worker reference as a privileged one, without the pin. If the card is already present, `add` behaves as `open`. An unknown card, or a product whose manifest lacks `includes.pocket`, produces a host error and no dialog. + +### Removing a card + +The user removes a card in host UI. The product removes one of its own with `remove_card`. Either way the card is gone: its cached face is discarded, its render subscription ends, its reference is dropped, and getting it back means the deeplink flow again. Removing a card that is not present succeeds. Removing a privileged card fails with `Privileged`, for the product, and is not offered to the user. + +### Wire surface + +Ids start after the highest allocated on `main` at draft time (194) and are re-checked when implemented. + +```rust +/// Pocket cards backed by the calling product. +#[crate::service(required_execution = Worker)] +#[crate::async_trait] +pub trait Pocket: Send + Sync { + /// The calling product's cards, whole set on subscribe and on every change. + #[wire(start_id = 198)] + async fn list_subscribe(&self, cx: &CallContext) -> Subscription; + + /// Remove one of the calling product's cards. Idempotent. + #[wire(request_id = 202)] + async fn remove_card( + &self, + cx: &CallContext, + request: HostPocketRemoveCardRequest, + ) -> Result>; + + /// Actions the user triggered on any of the calling product's faces. + #[wire(start_id = 204)] + async fn action_subscribe(&self, cx: &CallContext) -> Subscription; + + /// Streams the face of one card while it is on screen. Each item replaces the face. + #[wire(host_initiated, start_id = 208)] + fn card_render( + &self, + cx: &CallContext, + request: ProductPocketCardRenderRequest, + ) -> Subscription; +} +``` + +```rust +pub struct PocketCard { + pub card_id: String, + /// Placed by the host; cannot be removed. + pub privileged: bool, +} + +pub struct HostPocketListSubscribeItem { pub cards: Vec } + +pub struct HostPocketRemoveCardRequest { pub card_id: String } +/// Unit: removal has nothing to report beyond success. +pub struct HostPocketRemoveCardResponse; +pub enum HostPocketRemoveCardError { + /// The card is privileged. + Privileged, + Unknown { reason: String }, +} + +pub struct HostPocketActionSubscribeItem { + pub card_id: String, + /// `Button.click_action` or `TextField.value_change_action` from the face tree. + pub action_id: String, + pub payload: Option>, +} + +pub struct ProductPocketCardRenderRequest { pub card_id: String } +``` + +Each payload travels in a `V1` versioned envelope like every other method. A `card_render` item encodes byte-for-byte like a chat custom-message render item, so a host that already decodes one decodes the other. + +No request names a product: the host knows which worker it is talking to, so a product can neither observe nor remove another product's cards. A host with no Pocket surface answers `remove_card` with `Unavailable`, ends `list_subscribe` and `action_subscribe` at once with an empty Interrupt frame, and never opens `card_render`. + +## Trade-offs + +- **No product-initiated add.** A product cannot surface a card at the moment it becomes relevant; it has to get the user to a deeplink. Accepted: the collection is the user's, and a dialog per card is the consent. +- **The component set is reused as is.** Faces are built from the renderer nodes chat already has: no image or gradient backgrounds, no barcode or QR node, no aspect-ratio control. Those gaps are shared with chat and belong to a separate RFC so the two changes ship independently; nothing here depends on them. +- **The preview can lie.** The approved static face and the live face are both product-authored and nothing ties them together. The host can bound the drift by rendering both with the same component set, not by checking content. +- **Card definitions cost manifest budget.** Text records are small; a product with many cards pushes the Worker manifest toward the dotNS limit. Only ids, titles, and paths go in the manifest; the trees live in the archive. +- **Expanded cards need a Widget and RFC 0027.** A product with cards but no `widget` executable has faces that do not open. Products that want interaction without a WebView use face actions. +- **The start hook is module evaluation, not a call.** A dedicated host-initiated `start` method carrying the reason for the start was considered and dropped: it would run at the same moment and the first `card_render` request already says why the worker is up. +- **Reference counting is host-observable state the product cannot read.** A worker learns it is being terminated only by being terminated. Products must persist anything worth keeping as they go, which is already the Worker contract. +- **`-` as the reserved segment** is borrowed from GitLab's `/-/` namespace. Any App route starting with `/-/` is unreachable once a host implements this. A query parameter was rejected because Apps tend to ignore unknown parameters, so an unsupported target would silently open the App with no signal that anything was asked for. + +## Considerations + +- **Which products back Balance and Scarcity.** Humanity has an owner through RFC 0024. Balance and Scarcity are host-rendered today and this RFC assumes the host designates a product for each before iteration 1 ships. +- **Where card definitions live if the manifest budget bites.** The fallback is a single `pocket.json` at the archive root listing the cards, with the manifest carrying only `includes.pocket`. diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index 7b059b295..0ecf4315c 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -23,6 +23,7 @@ use truapi::api::{ Notifications, Payment, Permissions, + Pocket, Preimage, ResourceAllocation, Signing, @@ -58,6 +59,7 @@ where register_notifications(dispatcher, host.clone()); register_payment(dispatcher, host.clone()); register_permissions(dispatcher, host.clone()); + register_pocket(dispatcher, host.clone()); register_preimage(dispatcher, host.clone()); register_resource_allocation(dispatcher, host.clone()); register_signing(dispatcher, host.clone()); @@ -81,6 +83,21 @@ pub(crate) fn chat_custom_message_render( ) } +/// Start the host-initiated `pocket_card_render` subscription. +pub(crate) fn pocket_card_render( + subscriptions: &HostInitiatedSubscriptionManager, + transport: Arc, + request: versioned::pocket::ProductPocketCardRenderRequest, +) -> truapi::Subscription< + Result, +> { + subscriptions.start( + wire_table::POCKET_CARD_RENDER, + parity_scale_codec::Encode::encode(&request), + transport, + ) +} + fn register_account

(dispatcher: &mut Dispatcher, host: Arc

) where P: Account + Send + Sync + 'static, @@ -1996,6 +2013,85 @@ where } } +fn register_pocket

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Pocket + Send + Sync + 'static, +{ + { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); + let host = host.clone(); + dispatcher.on_subscription(wire_table::POCKET_LIST_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { return Err(Vec::new()); } + let stream = host.list_subscribe(&cx).await; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); + let host = host.clone(); + dispatcher.on_request(wire_table::POCKET_REMOVE_CARD, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::pocket::HostPocketRemoveCardRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } + let response: versioned::pocket::HostPocketRemoveCardResponse = match host.remove_card(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload( + downgrade_call_error(err, target_version), + target_version, + )); + } + }; + // Downgraded to the caller's version: a handler answers in + // latest terms, and a peer that asked in an older version + // cannot decode a newer variant. + Ok(encode_versioned_ok_payload( + ::from_latest( + truapi::versioned::IntoLatest::into_latest(response), + target_version, + ), + )) + }) + }); + } + { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); + let host = host; + dispatcher.on_subscription(wire_table::POCKET_ACTION_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { return Err(Vec::new()); } + let stream = host.action_subscribe(&cx).await; + Ok(subscription_stream::(stream)) + }) + }); + } +} + fn register_preimage

(dispatcher: &mut Dispatcher, host: Arc

) where P: Preimage + 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 5d59d7f93..15991f602 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,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 = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f3821ba92cb40583"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { @@ -516,6 +516,36 @@ pub const LOCALE_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { receive_id: 197, }; +/// Wire discriminants for `pocket_list_subscribe`. +pub const POCKET_LIST_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 198, + stop_id: 199, + interrupt_id: 200, + receive_id: 201, +}; + +/// Wire discriminants for `pocket_remove_card`. +pub const POCKET_REMOVE_CARD: RequestFrameIds = RequestFrameIds { + request_id: 202, + response_id: 203, +}; + +/// Wire discriminants for `pocket_action_subscribe`. +pub const POCKET_ACTION_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 204, + stop_id: 205, + interrupt_id: 206, + receive_id: 207, +}; + +/// Wire discriminants for `pocket_card_render`. +pub const POCKET_CARD_RENDER: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 208, + stop_id: 209, + interrupt_id: 210, + receive_id: 211, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -807,4 +837,20 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "locale_subscribe", kind: WireKind::Subscription(LOCALE_SUBSCRIBE), }, + WireEntry { + method: "pocket_list_subscribe", + kind: WireKind::Subscription(POCKET_LIST_SUBSCRIBE), + }, + WireEntry { + method: "pocket_remove_card", + kind: WireKind::Request(POCKET_REMOVE_CARD), + }, + WireEntry { + method: "pocket_action_subscribe", + kind: WireKind::Subscription(POCKET_ACTION_SUBSCRIBE), + }, + WireEntry { + method: "pocket_card_render", + kind: WireKind::Subscription(POCKET_CARD_RENDER), + }, ]; diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index e23c58ce0..1b1e1a802 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -14,7 +14,7 @@ use parity_scale_codec::Decode; use truapi::CallContext; use truapi::api::{ Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Locale, Notifications, Payment, - Permissions, Preimage, ResourceAllocation, Signing, StatementStore, System, Theme, + Permissions, Pocket, Preimage, ResourceAllocation, Signing, StatementStore, System, Theme, }; use truapi::versioned::{self, Versioned}; use truapi_platform::ProductExecutionKind; @@ -44,6 +44,7 @@ where register_notifications(dispatcher, host.clone()); register_payment(dispatcher, host.clone()); register_permissions(dispatcher, host.clone()); + register_pocket(dispatcher, host.clone()); register_preimage(dispatcher, host.clone()); register_resource_allocation(dispatcher, host.clone()); register_signing(dispatcher, host.clone()); @@ -67,6 +68,21 @@ pub(crate) fn chat_custom_message_render( ) } +/// Start the host-initiated `pocket_card_render` subscription. +pub(crate) fn pocket_card_render( + subscriptions: &HostInitiatedSubscriptionManager, + transport: Arc, + request: versioned::pocket::ProductPocketCardRenderRequest, +) -> truapi::Subscription< + Result, +> { + subscriptions.start( + wire_table::POCKET_CARD_RENDER, + parity_scale_codec::Encode::encode(&request), + transport, + ) +} + fn register_account

(dispatcher: &mut Dispatcher, host: Arc

) where P: Account + Send + Sync + 'static, @@ -2017,6 +2033,101 @@ where } } +fn register_pocket

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Pocket + Send + Sync + 'static, +{ + { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); + let host = host.clone(); + dispatcher.on_subscription( + wire_table::POCKET_LIST_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + return Err(Vec::new()); + } + let stream = host.list_subscribe(&cx).await; + Ok(subscription_stream::< + versioned::pocket::HostPocketListSubscribeItem, + _, + >(stream)) + }) + }, + ); + } + { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); + let host = host.clone(); + dispatcher.on_request(wire_table::POCKET_REMOVE_CARD, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::pocket::HostPocketRemoveCardRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + let error: truapi::CallError = + truapi::CallError::Denied; + return Ok(encode_versioned_err_payload(error, target_version)); + } + let response: versioned::pocket::HostPocketRemoveCardResponse = match host.remove_card(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload( + downgrade_call_error(err, target_version), + target_version, + )); + } + }; + // Downgraded to the caller's version: a handler answers in + // latest terms, and a peer that asked in an older version + // cannot decode a newer variant. + Ok(encode_versioned_ok_payload( + ::from_latest( + truapi::versioned::IntoLatest::into_latest(response), + target_version, + ), + )) + }) + }); + } + { + let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); + let host = host; + dispatcher.on_subscription( + wire_table::POCKET_ACTION_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + if !execution_allowed { + return Err(Vec::new()); + } + let stream = host.action_subscribe(&cx).await; + Ok(subscription_stream::< + versioned::pocket::HostPocketActionSubscribeItem, + _, + >(stream)) + }) + }, + ); + } +} + fn register_preimage

(dispatcher: &mut Dispatcher, host: Arc

) where P: Preimage + Send + Sync + 'static, diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 5d59d7f93..15991f602 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -50,7 +50,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 = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f3821ba92cb40583"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { @@ -516,6 +516,36 @@ pub const LOCALE_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { receive_id: 197, }; +/// Wire discriminants for `pocket_list_subscribe`. +pub const POCKET_LIST_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 198, + stop_id: 199, + interrupt_id: 200, + receive_id: 201, +}; + +/// Wire discriminants for `pocket_remove_card`. +pub const POCKET_REMOVE_CARD: RequestFrameIds = RequestFrameIds { + request_id: 202, + response_id: 203, +}; + +/// Wire discriminants for `pocket_action_subscribe`. +pub const POCKET_ACTION_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 204, + stop_id: 205, + interrupt_id: 206, + receive_id: 207, +}; + +/// Wire discriminants for `pocket_card_render`. +pub const POCKET_CARD_RENDER: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 208, + stop_id: 209, + interrupt_id: 210, + receive_id: 211, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -807,4 +837,20 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "locale_subscribe", kind: WireKind::Subscription(LOCALE_SUBSCRIBE), }, + WireEntry { + method: "pocket_list_subscribe", + kind: WireKind::Subscription(POCKET_LIST_SUBSCRIBE), + }, + WireEntry { + method: "pocket_remove_card", + kind: WireKind::Request(POCKET_REMOVE_CARD), + }, + WireEntry { + method: "pocket_action_subscribe", + kind: WireKind::Subscription(POCKET_ACTION_SUBSCRIBE), + }, + WireEntry { + method: "pocket_card_render", + kind: WireKind::Subscription(POCKET_CARD_RENDER), + }, ]; diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 1b037d2a0..403761be3 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -1166,6 +1166,33 @@ impl ProductRuntimeControl { }); Ok(truapi::Subscription::new(Box::pin(stream))) } + + /// Stream the face of one Pocket card from this connection's product + /// worker. Each item is a complete renderer tree replacing the previous one. + pub fn render_pocket_card( + &self, + card_id: String, + ) -> Result< + truapi::Subscription>, + ProductRuntimeError, + > { + self.runtime()?; + let request = truapi::versioned::pocket::ProductPocketCardRenderRequest::V1( + v01::ProductPocketCardRenderRequest { card_id }, + ); + let transport: Arc = self.transport.clone(); + let stream = crate::generated::dispatcher::pocket_card_render( + &self.host_subscriptions, + transport, + request, + ) + .map(|item| { + item.map(|item| match item { + truapi::versioned::pocket::ProductPocketCardRenderItem::V1(node) => node, + }) + }); + Ok(truapi::Subscription::new(Box::pin(stream))) + } } impl ProductRuntime { diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index c083b24a8..b4f92a65f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -87,7 +87,7 @@ use parity_scale_codec::Encode; use tracing::{debug, instrument, warn}; use truapi::api::{ Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Locale, Notifications, Payment, - Permissions, Preimage, ResourceAllocation, Signing, System, Theme, + Permissions, Pocket, Preimage, ResourceAllocation, Signing, System, Theme, }; use truapi::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, @@ -2910,6 +2910,15 @@ impl Locale for ProductRuntimeHost { } } +// --------------------------------------------------------------------------- +// Pocket +// --------------------------------------------------------------------------- + +// The trait defaults answer `Unavailable` and empty streams until a host +// surface backs the collection. +#[truapi::async_trait] +impl Pocket for ProductRuntimeHost {} + // `Notifications` delegates to the platform so hosts can own scheduling and // cancellation while the core preserves the typed TrUAPI wire shape. #[truapi::async_trait] diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index a4a7bb03f..3857be0c8 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -10,6 +10,7 @@ pub mod locale; pub mod notifications; pub mod payment; pub mod permissions; +pub mod pocket; pub mod preimage; pub mod resource_allocation; pub mod signing; @@ -27,6 +28,7 @@ pub use locale::Locale; pub use notifications::Notifications; pub use payment::Payment; pub use permissions::Permissions; +pub use pocket::Pocket; pub use preimage::Preimage; pub use resource_allocation::ResourceAllocation; pub use signing::Signing; @@ -46,6 +48,7 @@ pub trait TrUApi: + Notifications + Payment + Permissions + + Pocket + Preimage + ResourceAllocation + Signing @@ -68,6 +71,7 @@ impl TrUApi for T where + Notifications + Payment + Permissions + + Pocket + Preimage + ResourceAllocation + Signing diff --git a/rust/crates/truapi/src/api/pocket.rs b/rust/crates/truapi/src/api/pocket.rs new file mode 100644 index 000000000..377215ef0 --- /dev/null +++ b/rust/crates/truapi/src/api/pocket.rs @@ -0,0 +1,92 @@ +//! Unified [`Pocket`] trait. + +use crate::versioned::pocket::{ + HostPocketActionSubscribeItem, HostPocketListSubscribeItem, HostPocketRemoveCardError, + HostPocketRemoveCardRequest, HostPocketRemoveCardResponse, ProductPocketCardRenderItem, + ProductPocketCardRenderRequest, +}; +use crate::wire; +use crate::{CallContext, CallError, Subscription}; + +/// Pocket cards backed by the calling product. +/// +/// The host owns the collection: a product observes its own cards, streams +/// their faces on request, and may remove them, but cannot add one. +#[crate::service(required_execution = Worker)] +#[crate::async_trait] +pub trait Pocket: Send + Sync { + /// Subscribe to the calling product's cards. + /// + /// Emits the whole set on subscribe and again after every change. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const item = await firstValueFrom( + /// from(truapi.pocket.listSubscribe()), + /// ); + /// console.log("cards:", item.cards); + /// ``` + #[wire(start_id = 198)] + async fn list_subscribe(&self, _cx: &CallContext) -> Subscription { + Subscription::empty() + } + + /// Remove one of the calling product's cards. + /// + /// Removing a card that is not present succeeds. A privileged card is + /// refused with `Privileged`. + /// + /// ```ts + /// const result = await truapi.pocket.removeCard({ cardId: "loyalty" }); + /// assert(result.isOk(), "removeCard failed:", result); + /// console.log("card removed"); + /// ``` + #[wire(request_id = 202)] + async fn remove_card( + &self, + _cx: &CallContext, + _request: HostPocketRemoveCardRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Subscribe to actions the user triggers on any of the calling product's + /// card faces. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const action = await firstValueFrom( + /// from(truapi.pocket.actionSubscribe()), + /// ); + /// console.log("action received:", action.cardId, action.actionId); + /// ``` + #[wire(start_id = 204)] + async fn action_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription { + Subscription::empty() + } + + /// Streams the face of one card while it is on screen. + /// + /// Each item is a complete renderer tree that replaces the previous face. + /// The host keeps the newest tree and shows it while the worker is down. + /// + /// ```ts + /// import { of } from "rxjs"; + /// truapi.pocket.onCardRender(({ cardId }) => { + /// return of({ tag: "String", value: { text: `Card ${cardId}` } }); + /// }); + /// ``` + #[wire(host_initiated, start_id = 208)] + fn card_render( + &self, + _cx: &CallContext, + _request: ProductPocketCardRenderRequest, + ) -> Subscription { + Subscription::empty() + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 06d3557f4..e75281b18 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -51,17 +51,20 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, AllocationOutcome, ChainIdentifier, ChatAction, - ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoomRegistrationStatus, - ContextualAlias, DerivationIndex, GenericError, 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, + AccountId, AllocatableResource, AllocationOutcome, Arrangement, Background, BorderStyle, + BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, ChatActionLayout, + ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, + ChatMessageContent, ChatReaction, ChatRichText, ChatRoomRegistrationStatus, ColorToken, + ColumnProps, ContentAlignment, ContextualAlias, CustomRendererNode, DerivationIndex, + Dimensions, GenericError, HorizontalAlignment, HostPlatform, HostSignPayloadData, Modifier, + NotificationId, OperationStartedResult, OptionalBool, PocketCard, ProductAccountId, + ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, + RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, + RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, RingLocation, RingVrfKeyDisclosure, RingVrfPublicKey, + RowProps, RuntimeApi, RuntimeSpec, RuntimeType, Shape, SignedStatement, Size, Statement, + StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, + TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, }; /// Latest payload type of a versioned envelope. @@ -137,6 +140,20 @@ pub mod latest { pub type HostLocaleSubscribeItem = LatestOf; /// Navigation request error. pub type HostNavigateToError = LatestOf; + /// Face action delivered from the host to a product worker. + pub type HostPocketActionSubscribeItem = + LatestOf; + /// The calling product's Pocket cards. + pub type HostPocketListSubscribeItem = LatestOf; + /// Pocket card removal request. + pub type HostPocketRemoveCardRequest = LatestOf; + /// Pocket card removal failure. + pub type HostPocketRemoveCardError = LatestOf; + /// Pocket face render work sent by the host. + pub type ProductPocketCardRenderRequest = + LatestOf; + /// Pocket face tree streamed by a product worker. + pub type ProductPocketCardRenderItem = LatestOf; /// Push notification scheduling request. pub type HostPushNotificationRequest = LatestOf; diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index afb43cf7c..8101b5787 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -11,7 +11,9 @@ mod locale; mod notifications; mod payment; mod permissions; +mod pocket; mod preimage; +mod renderer; mod resource_allocation; mod signing; mod statement_store; @@ -30,7 +32,9 @@ pub use locale::*; pub use notifications::*; pub use payment::*; pub use permissions::*; +pub use pocket::*; 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..77d41083d 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. diff --git a/rust/crates/truapi/src/v01/pocket.rs b/rust/crates/truapi/src/v01/pocket.rs new file mode 100644 index 000000000..dd45c91c9 --- /dev/null +++ b/rust/crates/truapi/src/v01/pocket.rs @@ -0,0 +1,60 @@ +use parity_scale_codec::{Decode, Encode}; + +/// One of the calling product's Pocket cards. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PocketCard { + /// Card label declared by the product, unique within the product. + pub card_id: String, + /// Placed by the host itself; removable by neither the user nor the product. + pub privileged: bool, +} + +/// The calling product's cards: the whole set on subscribe and after every change. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostPocketListSubscribeItem { + /// Cards currently in Pocket for the calling product. + pub cards: Vec, +} + +/// Request to remove one of the calling product's cards. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostPocketRemoveCardRequest { + /// Card to remove. A card that is not present is already removed. + pub card_id: String, +} + +/// Card removal failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostPocketRemoveCardError { + /// The card is privileged and stays in Pocket. + Privileged, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} + +/// An action the user triggered on one of the calling product's card faces. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostPocketActionSubscribeItem { + /// Card whose face carried the action. + pub card_id: String, + /// `Button.click_action` or `TextField.value_change_action` from the face tree. + pub action_id: String, + /// Optional additional data, such as the new text-field value. + pub payload: Option>, +} + +/// Render work sent by the host while a card's face is on screen. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ProductPocketCardRenderRequest { + /// Card whose face to stream. + pub card_id: String, +} diff --git a/rust/crates/truapi/src/v01/chat/custom_renderer.rs b/rust/crates/truapi/src/v01/renderer.rs similarity index 100% rename from rust/crates/truapi/src/v01/chat/custom_renderer.rs rename to rust/crates/truapi/src/v01/renderer.rs diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 4d5e37c26..7b9ab2755 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -40,6 +40,7 @@ pub mod locale; pub mod notifications; pub mod payment; pub mod permissions; +pub mod pocket; pub mod preimage; pub mod resource_allocation; pub mod signing; diff --git a/rust/crates/truapi/src/versioned/pocket.rs b/rust/crates/truapi/src/versioned/pocket.rs new file mode 100644 index 000000000..4311750e7 --- /dev/null +++ b/rust/crates/truapi/src/versioned/pocket.rs @@ -0,0 +1,62 @@ +//! Versioned wrappers for [`Pocket`](crate::api::Pocket) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostPocketListSubscribeItem { V1 => v01::HostPocketListSubscribeItem } + pub enum HostPocketRemoveCardRequest { V1 => v01::HostPocketRemoveCardRequest } + pub enum HostPocketRemoveCardResponse { V1 } + pub enum HostPocketRemoveCardError { V1 => v01::HostPocketRemoveCardError } + pub enum HostPocketActionSubscribeItem { V1 => v01::HostPocketActionSubscribeItem } + pub enum ProductPocketCardRenderRequest { V1 => v01::ProductPocketCardRenderRequest } + pub enum ProductPocketCardRenderItem { V1 => v01::CustomRendererNode } +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::{Decode, Encode}; + + // A face action must name the card it came from, so one worker handler can + // serve every card of the product. The fixture pins the field order the + // host and the generated client agree on. + #[test] + fn action_item_carries_card_then_action_then_payload() { + let item = HostPocketActionSubscribeItem::V1(v01::HostPocketActionSubscribeItem { + card_id: "loyalty".into(), + action_id: "redeem".into(), + payload: None, + }); + + assert_eq!( + hex::encode(item.encode()), + "001c6c6f79616c74791872656465656d00" + ); + assert_eq!( + HostPocketActionSubscribeItem::decode(&mut item.encode().as_slice()).unwrap(), + item + ); + } + + // The face stream reuses the chat renderer tree unchanged, so a host that + // renders chat custom messages renders Pocket faces with the same decoder. + #[test] + fn face_item_encodes_like_a_chat_render_item() { + let node = v01::CustomRendererNode::String { + text: "Votes: 1".into(), + }; + let face = ProductPocketCardRenderItem::V1(node.clone()); + let chat = crate::versioned::chat::ProductChatCustomMessageRenderItem::V1(node); + + assert_eq!(face.encode(), chat.encode()); + } + + // Privileged cards are the only removal the protocol refuses, and its + // discriminant must stay first so older clients keep decoding it. + #[test] + fn privileged_removal_error_is_discriminant_zero() { + let error = HostPocketRemoveCardError::V1(v01::HostPocketRemoveCardError::Privileged); + + assert_eq!(hex::encode(error.encode()), "0000"); + } +}