Skip to content

feat(platform,server): match manifest execution kinds and serve custom chat rendering from a JS host - #459

Open
johnthecat wants to merge 1 commit into
mainfrom
desktop-fixes
Open

feat(platform,server): match manifest execution kinds and serve custom chat rendering from a JS host#459
johnthecat wants to merge 1 commit into
mainfrom
desktop-fixes

Conversation

@johnthecat

Copy link
Copy Markdown
Contributor

Three changes that Polkadot Desktop needed to move its worker onto the shared core. They are separable in principle, but the second depends on the first naming a worker execution, and the third is a correctness fix in the machinery the second uses.

Execution kinds match the manifest

ProductExecutionKind is App | Widget | Worker, matching the executable kinds a product manifest declares (["app", "widget", "worker"] in dotli's packages/shared/src/executables.ts).

App and Widget are one capability class. Execution gating is exact equality and no service requires either kind specifically, so they reach the same APIs and differ only in how the host presents them. Worker is the only kind that may serve Chat.

The SCALE discriminants are App=0, Widget=1, Worker=2. The worker kind sits at 2, so this is a wire change and not only a rename. A host that keeps encoding 1 for its worker now claims to be a widget and loses Chat access. Paired Rust and TypeScript encoding tests pin all three indices, and the wasm entrypoint rejects an unrecognised kind at construction rather than falling back to a default.

The explorer's compatibility page matches the set of kind names that serve Chat, so its frozen per-version snapshots keep resolving against the names each released version declared.

Custom chat messages reach a JS host

Two host-initiated entry points on TrUApiProductProvider, present on runtimes that hold a live channel to the core:

renderCustomMessage?(
  request: { messageId: string; messageType: string; payload: Uint8Array },
  sink: {
    onUpdate(node: CustomRendererNode): void;
    onComplete?(): void;
    onError?(error: Error): void;
  },
): () => void;

publishChatAction?(action: HostChatActionSubscribeItem): Promise<void>;

renderCustomMessage streams the trees a product draws for one stored custom message. publishChatAction carries a tapped button back to the product. A host with only the first would draw cells whose buttons do nothing, which is worse than drawing nothing, so both land together.

Both sit behind the Chat access policy that already governs every other Chat call. A connection that is not a Worker execution with a live session is refused.

ChatConnection::publish_action compiles for wasm. It buffers an action in memory until the product subscribes, with nothing native about it.

Host-initiated subscriptions report failure

A product that cannot serve a render sends _interrupt. A product that finishes sends nothing at all, and its last tree stays on screen until the host stops the render (js/packages/truapi/src/client.ts:405-433).

HostInitiatedSubscription yields Result<Item, GenericError> so those two outcomes stay distinct. An interrupt and an item that fails to decode are errors. A dropped sender is a clean end, because that is the host closing the manager or disposing the core rather than the product failing. handle_message delivers the interrupt to the stream before dropping the sender.

The host contract is exactly one terminal per render. onComplete means the last tree delivered stands. onError means it is partial and must not be shown as final. Without this, a failed render reports completion and a host draws half a message as though the product had finished.

NativeCustomRendererObserver gains on_error(reason). The Swift conformer is an AsyncThrowingStream and finishes throwing a CustomRendererStreamError.

Two crash-shaped bugs in the worker bridge

Both found while reviewing the above, and both invisible to a single-provider smoke test.

A tree the host's codec cannot decode, or a renderer that throws on one, ends that render through onError. Such a throw escaping the worker message listener leaves the render with no terminal at all: the sink stays registered, the wasm subscription stays live, and a caller awaiting a terminal waits forever. Products built against a newer protocol than the host's bundled @parity/truapi are the realistic trigger.

Render subscriptions are keyed by core on both sides of the worker boundary, and a disposed provider fails its outstanding renders. Desktop runs one provider per open product plus one per worker, so cross-product cancellation is reachable there.

Verification

cargo test --workspace --all-features (26 result blocks, 0 failures), cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo +nightly fmt --all --check, cargo check --target wasm32-unknown-unknown -p truapi-server, codegen goldens, sync-bindings.sh --check, @parity/truapi 231 tests, @parity/truapi-host 64 tests, explorer typecheck, playground build and lint.

The Swift package is not built locally. CI's ios-swift job covers the hand-written conformer, which is where CustomRendererStreamError and the new protocol method live.

js/packages/truapi-host/src/host-callbacks-adapter.test.ts typechecks. tsconfig.json excludes test files from tsc -b and bun test transpiles without typechecking, so nothing in CI reads them. Eleven type errors had accumulated there. The other test files in the package are worth the same sweep.

…m chat rendering from a JS host

`ProductExecutionKind` named two surfaces while product manifests declare
three, so a widget had to masquerade as an app. Rename the variants to match
`["app", "widget", "worker"]`: `Spa` becomes `App`, `Widget` is new, and
`Worker` replaces `Chat`. `App` and `Widget` are one capability class, since
execution gating is exact equality and nothing requires either specifically.
This moves the worker kind's SCALE discriminant from 1 to 2, so it is a wire
change rather than a rename; the wasm entrypoint now rejects an unknown kind at
construction instead of degrading silently.

Add the two host-initiated Chat entry points a JS host was missing.
`renderCustomMessage` streams product-drawn trees for one stored custom
message, and `publishChatAction` carries a tapped action back to the product.
Shipping only the first would render cells whose buttons do nothing, so both
land together. `ChatConnection::publish_action` was excluded from wasm by a
`cfg`, despite being in-memory buffering with nothing native about it.

Give host-initiated subscriptions an error terminal. A product that cannot
serve a render sends `_interrupt`; one that finishes sends nothing and leaves
its last tree standing. Both previously ended the stream the same way, so a
failed render reported completion and the host would show a partial tree as
final. `HostInitiatedSubscription` now yields `Result`, and the interrupt
payload reaches the stream instead of being dropped on the floor. Host-side
cancellation stays a clean end, because it is not a product failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@johnthecat
johnthecat requested a review from a team August 19, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant