diff --git a/.changeset/pocket-modality.md b/.changeset/pocket-modality.md new file mode 100644 index 000000000..47a104841 --- /dev/null +++ b/.changeset/pocket-modality.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": minor +--- + +Add the `pocket` service: `listSubscribe` over the calling product's cards, and `removeCard`. diff --git a/docs/rfcs/pocket-modality.md b/docs/rfcs/pocket-modality.md new file mode 100644 index 000000000..e6413108d --- /dev/null +++ b/docs/rfcs/pocket-modality.md @@ -0,0 +1,238 @@ +--- +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. + +A face is drawn through the [Unified Renderer](https://github.com/paritytech/host-rust-core/pull/633)'s `PocketCard` +context. The collection itself is one `Pocket` trait with two methods, a Pocket section in the Worker manifest, and a +deeplink grammar that names a target modality. + +Tracking issue: [#563](https://github.com/paritytech/host-rust-core/issues/563). + +## Motivation + +The iOS host shows Humanity, Balance and Scarcity as cards whose content and interactions are hard-coded into it. +Personhood is becoming a product ([RFC 0024](0024-personhood-as-product.md)), which declares +`includes: { pocket: true }` and expects a card. No contract stands behind that flag: nothing says which cards a product +may back, how one enters the collection, or who can take it out. + +The first iteration ships three host-placed cards before anyone publishes one, so the collection rules hold for a +host-placed card on their own and the add flow layers on top without changing them. + +## Approach + +### Model + +- A **card** is identified by `(product_id, card_id)`. `card_id` is a short label the product declares, screened by the + same rules as a chat identifier (see Wire surface). +- The **face** is the collapsed presentation, rendered natively from a `RendererNode` tree. +- The **expanded card** is the product's `widget` executable in a WebView. +- The **worker** is the product's one Worker executable. It draws the faces, handles their 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 + +A face is a body drawn through [Renderer](https://github.com/paritytech/host-rust-core/pull/633), on the +`PocketCard { card_id }` context. The host opens a `render` stream while the face is on screen and gets a `RendererNode` +tree per item; presses and edits inside the tree arrive on `action_subscribe` under the same context, so one handler +serves every card of the product. + +Pocket adds one rule on top: the host caches the newest tree per card durably, so a face is shown offline and at cold +start before the worker answers, and a privileged card has something to show on first run. + +### Expanded card + +Tapping a face opens the product's `widget` executable with the card named in the launch URL query, `card=`. +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 + +Pocket holds one worker reference per card whose face is on screen. A product cannot add a reference, only drop one by +removing a card. + +### 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; // Card label, unique within the product. Screened as a chat identifier is. + title: string; // Shown in the approval dialog and in host chrome. + preview: string; // Path inside the worker archive to a RendererNode 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 + H->>W: start, register renderer handlers + H->>H: face on screen, reference 0 → 1 + H->>W: renderer.render { context: PocketCard { 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 `Renderer::render` stream 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 + +Pocket occupies 206 to 211. The highest id allocated on `main` is 197, because `locale.subscribe` declares +`start_id = 194` and a subscription spans four ids (start, stop, interrupt, receive). The eight ids between are not +truapi's to take: the product SDK's `host-api` package appends its own SDK-only methods directly after the last truapi +id, so `host_local_storage_subscribe`, `host_worker_begin_operation` and `host_worker_end_operation` already occupy 198 +to 201, 202 to 203, and 204 to 205. Pocket starts above that block. + +This only holds until the next SDK-side addition, because that package numbers by appending and truapi and the SDK grow +into one shared space from the same end. [#357](https://github.com/paritytech/host-rust-core/pull/357) removes the +contention properly by splitting the frame discriminant into a trait byte and a method byte, which retires flat +allocation; Pocket would then hold a trait byte with methods 0 and 1 and the ids below become history. Until it lands, +an id range reserved for SDK extensions is what keeps the two from meeting. + +```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 = 206)] + async fn list_subscribe(&self, cx: &CallContext) -> Subscription; + + /// Remove one of the calling product's cards. Idempotent. + #[wire(request_id = 210)] + async fn remove_card( + &self, + cx: &CallContext, + request: HostPocketRemoveCardRequest, + ) -> Result>; +} +``` + +```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 } +pub enum HostPocketRemoveCardError { + /// The card is privileged. + Privileged, + Unknown { reason: String }, +} +``` + +Each payload travels in a `V1` versioned envelope like every other method. `HostPocketRemoveCardResponse` carries no +payload of its own: removal has nothing to report beyond success, so its envelope is the bare `V1` variant, as every +other unit response is. + +**Bounds.** `card_id` is a product-supplied identifier and carries the rules chat already applies to its own, through +`normalize_chat_identifier`: trimmed, NFC-normalized, non-empty, at most `CHAT_FIELD_MAX_BYTES` (256) bytes once +normalized, and rejected if it contains characters that let two distinct ids render identically (joiners, variation +selectors, soft hyphens, non-ASCII spaces). Pocket reuses the constant rather than declaring its own, so the modalities +cannot drift; chat acquired these bounds only after hosts had already diverged +([#453](https://github.com/paritytech/host-rust-core/pull/453)), which is the outcome stating them here avoids. Action +ids and action payloads are the renderer's, and bounded there. + +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` and ends +`list_subscribe` at once with an empty Interrupt frame. + +## 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 node vocabulary is the renderer's.** A face cannot ask for a node the renderer does not carry, so gaps such as a + barcode node or aspect-ratio control are argued there and close for every surface at once. +- **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 from the same node vocabulary, 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.** A product with cards but no `widget` executable has faces that do not open. Products + that want interaction without a WebView use face actions. `Pocket` is reachable only from the worker, so an expanded + card that wants to offer "Remove from Pocket" has to reach its own worker to do it. +- **Pocket declares no rendering method of its own.** A `card_render` and `action_subscribe` pair keyed by `card_id` was + considered and dropped: a face is a body like any other, and a pair per surface costs four wire ids each and gives a + product one handler per surface to register. +- **`-` 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`. +- **Material effects on a face.** Predefined surface effects a card picks, animated by device orientation. Not specified + here: a privileged card is host-placed, so a host can attach one by card identity, and anything a third-party card + picks belongs in the renderer's `Effect` vocabulary. diff --git a/playground/tests/unit/chat-diagnosis.test.ts b/playground/tests/unit/chat-diagnosis.test.ts index 866ca004d..b8a2e4800 100644 --- a/playground/tests/unit/chat-diagnosis.test.ts +++ b/playground/tests/unit/chat-diagnosis.test.ts @@ -7,7 +7,7 @@ describe("ChatDiagnosis", () => { // Expectation comes from codegen, so a missing method fails here. test("covers every generated Chat method", () => { const generated = servicesForExecution(generatedServices, "Worker") - .filter((service) => service.requiredExecution === "Worker") + .filter((service) => service.name === "Chat") .flatMap((service) => service.methods.map((method) => `${service.name}/${method.name}`), ); diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index 7b059b295..590849027 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()); @@ -1996,6 +1998,71 @@ 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; + 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, + ), + )) + }) + }); + } +} + 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..cf8a71bc5 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 = "2dc87781608853bd"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { @@ -516,6 +516,20 @@ pub const LOCALE_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { receive_id: 197, }; +/// Wire discriminants for `pocket_list_subscribe`. +pub const POCKET_LIST_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 206, + stop_id: 207, + interrupt_id: 208, + receive_id: 209, +}; + +/// Wire discriminants for `pocket_remove_card`. +pub const POCKET_REMOVE_CARD: RequestFrameIds = RequestFrameIds { + request_id: 210, + response_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 +821,12 @@ 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), + }, ]; diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 86fd42cd7..53428c432 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -56,7 +56,7 @@ pub(crate) use signing_host::{ }; pub use signing_host::{PairedSsoPeer, ResponderExit}; use tracing::{instrument, warn}; -use truapi::api::Chat; +use truapi::api::{Chat, Pocket}; use truapi::versioned::account::{HostAccountGetError, HostAccountSignVrfError}; use truapi::versioned::chat::{ HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, @@ -1025,6 +1025,12 @@ impl Chat for ProductRuntimeHost { self.chat.subscribe_actions() } } + +// The trait defaults answer `Unavailable` and empty streams until a host +// surface backs the collection. +#[truapi::async_trait] +impl Pocket for ProductRuntimeHost {} + /// Report a rejected chat bot field as a bot-registration domain error. fn chat_register_bot_field_error( error: truapi_platform::ChatFieldError, 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..0f3a4c393 --- /dev/null +++ b/rust/crates/truapi/src/api/pocket.rs @@ -0,0 +1,52 @@ +//! Unified [`Pocket`] trait. + +use crate::versioned::pocket::{ + HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, + HostPocketRemoveCardResponse, +}; +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 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 = 206)] + 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 = 210)] + async fn remove_card( + &self, + _cx: &CallContext, + _request: HostPocketRemoveCardRequest, + ) -> Result> { + Err(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 06d3557f4..53f79fd50 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -55,8 +55,8 @@ pub mod latest { ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoomRegistrationStatus, ContextualAlias, DerivationIndex, GenericError, HostPlatform, HostSignPayloadData, - NotificationId, OperationStartedResult, ProductAccountId, ProductProofContext, RawPayload, - RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, + NotificationId, OperationStartedResult, PocketCard, ProductAccountId, ProductProofContext, + RawPayload, RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, RingVrfKeyDisclosure, RingVrfPublicKey, RuntimeApi, RuntimeSpec, RuntimeType, @@ -137,6 +137,12 @@ pub mod latest { pub type HostLocaleSubscribeItem = LatestOf; /// Navigation request error. pub type HostNavigateToError = 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; /// 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..4cf8a1179 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -11,6 +11,7 @@ mod locale; mod notifications; mod payment; mod permissions; +mod pocket; mod preimage; mod resource_allocation; mod signing; @@ -30,6 +31,7 @@ pub use locale::*; pub use notifications::*; pub use payment::*; pub use permissions::*; +pub use pocket::*; pub use preimage::*; pub use resource_allocation::*; pub use signing::*; diff --git a/rust/crates/truapi/src/v01/pocket.rs b/rust/crates/truapi/src/v01/pocket.rs new file mode 100644 index 000000000..80bea6b00 --- /dev/null +++ b/rust/crates/truapi/src/v01/pocket.rs @@ -0,0 +1,40 @@ +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, + }, +} 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..83c04e65d --- /dev/null +++ b/rust/crates/truapi/src/versioned/pocket.rs @@ -0,0 +1,25 @@ +//! 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 } +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::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"); + } +}