From 7f47b42d6f4465ec27b52b363766f7b7dcf5567d Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 11:16:06 -0300 Subject: [PATCH 01/29] docs(rfc): let a product scan a code with the host's viewfinder Adds the Host-drawn scanner RFC and its protocol surface: the Scanner trait (wire id 25) with scan, its v01 payloads and versioned envelopes, and the regenerated goldens. The runtime answers Unsupported until a host serves it, so the CLI battery and the playground Diagnosis skip the service for now. --- .changeset/host-scanner.md | 5 + README.md | 5 + docs/rfcs/host-scanner.md | 136 ++++++++++++++++++ playground/src/lib/auto-test.ts | 1 + .../truapi-codegen/tests/golden/dispatcher.rs | 36 +++++ .../truapi-codegen/tests/golden/wire_table.rs | 12 +- .../truapi-host-cli/js/diagnosis.test.ts | 1 + rust/crates/truapi-host-cli/js/diagnosis.ts | 2 +- rust/crates/truapi/src/api.rs | 4 + rust/crates/truapi/src/api/scanner.rs | 50 +++++++ rust/crates/truapi/src/lib.rs | 14 +- .../crates/truapi/src/runtime/capabilities.rs | 1 + .../src/runtime/capabilities/scanner.rs | 22 +++ rust/crates/truapi/src/runtime/tests.rs | 22 ++- rust/crates/truapi/src/v01.rs | 2 + rust/crates/truapi/src/v01/scanner.rs | 96 +++++++++++++ rust/crates/truapi/src/versioned.rs | 1 + rust/crates/truapi/src/versioned/scanner.rs | 9 ++ 18 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 .changeset/host-scanner.md create mode 100644 docs/rfcs/host-scanner.md create mode 100644 rust/crates/truapi/src/api/scanner.rs create mode 100644 rust/crates/truapi/src/runtime/capabilities/scanner.rs create mode 100644 rust/crates/truapi/src/v01/scanner.rs create mode 100644 rust/crates/truapi/src/versioned/scanner.rs diff --git a/.changeset/host-scanner.md b/.changeset/host-scanner.md new file mode 100644 index 0000000000..5b384cdb32 --- /dev/null +++ b/.changeset/host-scanner.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": minor +--- + +Adds the `scanner` service. `scan` asks the host to open its own QR and barcode viewfinder and answers with the scanned code's text and format. Hosts answer `Unsupported` until they serve it. diff --git a/README.md b/README.md index 94c548b78d..7b6c19a02e 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,11 @@ roles still install an in-memory `CliGameHost` that never rings anything. The withdrawn before it reaches the host, the absence of any permission prompt, and host failures. +`Scanner` (`scan`) opens the host's own QR and barcode viewfinder (RFC +"Host-drawn scanner"). No host serves it yet, so the runtime +answers `Unsupported`, and the battery and the playground's Diagnosis both skip +the service. + To run the playground locally in a plain browser tab, against a signing host on your own machine: diff --git a/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md new file mode 100644 index 0000000000..762cdb9fd5 --- /dev/null +++ b/docs/rfcs/host-scanner.md @@ -0,0 +1,136 @@ +--- +title: "Host-drawn scanner" +owner: "@valentinfernandez1" +status: draft +--- + +# RFC — Host-drawn scanner + +## Summary + +A product asks the host to open its native QR and barcode viewfinder. The host draws the viewfinder, the user points it +at a code, and the product receives that code's text and format. The product never receives camera frames, needs no +camera permission, and the host does not act on what was scanned. + +## Motivation + +Every product that scans a code today builds its own viewfinder, so each one looks and behaves differently while both +native hosts already ship a polished scanner of their own. Scanning a receipt, a ticket or an address should feel the +same in every product. + +A Worker execution has no camera at all, so a Pocket card cannot offer to scan anything. A page execution can call +`getUserMedia` after a `Camera` grant, but it then ships its own decoder and viewfinder, and it receives the whole video +stream for a task that needs one string. + +## Approach + +A new `Scanner` trait has one method. The product names the formats it accepts and, optionally, a prefix the text must +start with and a one-line hint. The host opens its viewfinder over whatever is on screen and closes it on the first code +that passes the filter. Codes that do not pass are ignored: the viewfinder stays open and the host briefly says the code +is not for this product. + +```ts +const result = await truapi.scanner.scan({ + formats: ["Qr"], + prefix: "https://greenmarket.example/r/", + hint: "Point at the receipt's QR code", +}); +assert(result.isOk(), "scanner.scan failed:", result); +const outcome = result.value.outcome; +switch (outcome.tag) { + case "Scanned": + console.log(outcome.value.format, outcome.value.text); + break; + case "Dismissed": + console.log("the user closed the viewfinder; worth offering again"); + break; +} +``` + +```rust +/// Code formats the host scanner reads. +pub enum CodeFormat { + Qr, Aztec, DataMatrix, Pdf417, + /// Also UPC-A, reported with a leading zero. + Ean13, + Ean8, UpcE, Code128, Code39, Code93, Itf, +} + +pub struct HostScannerScanRequest { + /// Formats the product accepts. At least one. + pub formats: Vec, + /// Exact, case-sensitive start the text must have. At most 256 bytes. + pub prefix: Option, + /// One line of plain text shown under the host's title. At most 80 characters. + pub hint: Option, +} + +pub enum ScanOutcome { + Scanned { text: String, format: CodeFormat }, + Dismissed, +} + +pub struct HostScannerScanResponse { + pub outcome: ScanOutcome, +} + +pub enum HostScannerScanError { + /// No camera, or the user refused the host application one. + CameraUnavailable, + /// Another scan is already open. + Busy, + /// No formats, or a prefix or hint over its limit. + InvalidRequest { reason: String }, + Unknown { reason: String }, +} + +#[wire_trait(id = 25)] +pub trait Scanner { + #[wire(id = 0)] + async fn scan(&self, cx: &CallContext, request: HostScannerScanRequest) + -> Result>; +} +``` + +`Dismissed` is worth offering again, `CameraUnavailable` is not until the user changes a setting, and `Unsupported` +(a host with no scanner) never is on that host. One code per call: a product that scans several calls again. +Cancelling the call closes the viewfinder ([Request cancellation](request-cancellation.md)). + +**The host owns the viewfinder.** It writes the title itself, naming the requesting product by its id ("Scan for +greenmarket.dot"), and shows the product's hint below it as plain text. A product cannot draw over the viewfinder or +title it, so it cannot dress up a request as a host instruction such as "scan to sign in". + +**The host does not act on what it scanned.** The host's own scanner treats some codes as deep links: a link to another +product opens it, a pairing link starts sign-in, a payment link opens a payment screen. A product's scan skips all of +that and returns the text. A product that wants to follow a scanned link calls `navigate_to`, under the rules that call +already has. + +**No permission.** The product never touches the camera, and the user pointing the host's viewfinder at a code is the +consent. The host application's own OS camera prompt still applies and is shown inside the host's flow. The `Camera` +permission and `getUserMedia` are unchanged: a product that needs the camera for anything else, or wants its own +scanning UI, keeps doing so. + +**The core checks both sides.** It refuses an invalid request before any viewfinder is raised, allows one open scan per +host, and refuses a host answer whose format the product did not accept or whose text lacks the prefix. The native hosts +filter codes in the viewfinder with the same rule, exported from the core, so iOS and Android accept exactly the same +codes. Scanning needs no signed-in session. + +**Hosts.** iOS and Android draw the viewfinder with the platform decoders they already use (AVFoundation and ML Kit), +whose common formats are the list above. A web host answers `Unsupported`. The headless CLI host and the test host +answer with a code the test supplies, so a product can test its scan flow without a camera. + +## Trade-offs + +- Text only. A code whose content is not valid text is skipped like one that fails the filter. Raw bytes can be added + later as an optional field without breaking existing callers. +- A prefix is the only content filter. A product that needs finer checks validates the text itself and calls again. +- Any execution can call it, including a Worker the user is not looking at. The title always names the product and the + user can close it, which is the exposure the contact picker already has. +- Web hosts have no scanner yet, so a product keeps its own `getUserMedia` fallback there. A bundled decoder can be + added later without changing this API. +- The host's main scanner still cannot hand a code to a product. A product claiming codes it handles is a separate + feature. +- Dropped: a product-written title (lets a product pose as the host); a regular-expression filter (iOS, Android and + browsers disagree on regex syntax); a live accept/reject exchange per code (more protocol for a need no product has + yet); the host routing scanned deep links (a product's scan would leave the product); a `Camera` grant on `scan` + (the product never receives camera data). diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 320e4a46f5..a40ec762a1 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -34,6 +34,7 @@ const SKIPPED_SERVICES = new Map([ "Game", "dot.li serves no Game surface; the truapi runtime tests cover it", ], + ["Scanner", "dot.li has no camera viewfinder; it answers Unsupported"], ]); // Methods that trigger a host permission/signing prompt, so they need the // longer signing-class timeout to allow for the user to respond. diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index 3fa814b215..821e5d8ea0 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -29,6 +29,7 @@ use truapi::api::{ Preimage, Renderer, ResourceAllocation, + Scanner, Signing, StatementStore, System, @@ -67,6 +68,7 @@ where register_preimage(dispatcher, host.clone()); register_renderer(dispatcher, host.clone()); register_resource_allocation(dispatcher, host.clone()); + register_scanner(dispatcher, host.clone()); register_signing(dispatcher, host.clone()); register_statement_store(dispatcher, host.clone()); register_system(dispatcher, host.clone()); @@ -2083,6 +2085,40 @@ where } } +fn register_scanner

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Scanner + Send + Sync + 'static, +{ + { + let host = host; + dispatcher.on_request(wire_table::SCANNER_SCAN, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::scanner::HostScannerScanRequest = match DecodeAll::decode_all(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + let result: Result> = Err(error); + return result.encode(); + } + }; + let target_version = request.version(); + let cx = CallContext::with_parts(request_id, cancel); + let result: Result> = + match host.scan(&cx, request).await { + Ok(response) => Ok(::from_latest( + truapi::versioned::IntoLatest::into_latest(response), + target_version, + )), + Err(err) => Err(downgrade_call_error(err, target_version)), + }; + result.encode() + }) + }); + } +} + fn register_signing

(dispatcher: &mut Dispatcher, host: Arc

) where P: Signing + 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 81be81a415..7b562cc976 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 = "f1965fda63b2fdb4"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "37f7502eac439ff0"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { @@ -554,6 +554,12 @@ pub const GAME_CANCEL_NEXT_GAME: MethodIds = MethodIds { method_id: 1, }; +/// Wire discriminants for `scanner_scan`. +pub const SCANNER_SCAN: MethodIds = MethodIds { + trait_id: 25, + method_id: 0, +}; + /// 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. @@ -898,4 +904,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "game_cancel_next_game", kind: WireKind::Request(GAME_CANCEL_NEXT_GAME), }, + WireEntry { + method: "scanner_scan", + kind: WireKind::Request(SCANNER_SCAN), + }, ]; diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index 3d558fcf06..ddba9df520 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -53,6 +53,7 @@ describe("generated-example battery", () => { const unsupported = new Map([ ["Coin Payment", "Coin Payment service not yet wired up by hosts"], ["Payment", "Payment service not yet wired up by hosts"], + ["Scanner", "Scanner service not yet wired up by hosts"], [ "Game", "Game serves only the game product; the truapi runtime tests cover it", diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts index 525daf6f55..ed91abf990 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -19,7 +19,7 @@ const UNARY_TIMEOUT_MS = 10_000; const REMOTE_RESPONSE_TIMEOUT_MS = 190_000; const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; const APP_SERVICES = servicesForExecution(services, "App"); -const UNWIRED_SERVICES = ["Coin Payment", "Payment"]; +const UNWIRED_SERVICES = ["Coin Payment", "Payment", "Scanner"]; const SKIPPED_SERVICES = new Map([ ...UNWIRED_SERVICES.map( (name) => [name, `${name} service not yet wired up by hosts`] as const, diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 5baaf625a3..126c506c47 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -16,6 +16,7 @@ pub mod pocket; pub mod preimage; pub mod renderer; pub mod resource_allocation; +pub mod scanner; pub mod signing; pub mod statement_store; pub mod system; @@ -38,6 +39,7 @@ pub use pocket::Pocket; pub use preimage::Preimage; pub use renderer::Renderer; pub use resource_allocation::ResourceAllocation; +pub use scanner::Scanner; pub use signing::Signing; pub use statement_store::StatementStore; pub use system::System; @@ -62,6 +64,7 @@ pub trait TrUApi: + Preimage + Renderer + ResourceAllocation + + Scanner + Signing + StatementStore + System @@ -89,6 +92,7 @@ impl TrUApi for T where + Preimage + Renderer + ResourceAllocation + + Scanner + Signing + StatementStore + System diff --git a/rust/crates/truapi/src/api/scanner.rs b/rust/crates/truapi/src/api/scanner.rs new file mode 100644 index 0000000000..669a90502b --- /dev/null +++ b/rust/crates/truapi/src/api/scanner.rs @@ -0,0 +1,50 @@ +//! Unified [`Scanner`] trait. + +use crate::versioned::scanner::{ + HostScannerScanError, HostScannerScanRequest, HostScannerScanResponse, +}; +use crate::{CallContext, CallError}; +use crate::{wire, wire_trait}; + +/// QR codes and barcodes scanned through the host's own viewfinder. +/// +/// The product receives the one code the user scanned, never camera frames, +/// so there is no permission to request: pointing the host's viewfinder at a +/// code is the consent. The host does not act on what it scanned, so a link +/// comes back as text. A product that needs the camera for anything else keeps +/// using `getUserMedia` under the `Camera` permission. +#[wire_trait(id = 25)] +#[crate::async_trait] +pub trait Scanner: Send + Sync { + /// Ask the host to let the user scan one code. + /// + /// The host ignores codes outside `formats` or without `prefix` and keeps + /// the viewfinder open. A host with no scanner answers `Unsupported`, and + /// cancelling the call closes the viewfinder. + /// + /// ```ts + /// const result = await truapi.scanner.scan({ + /// formats: ["Qr"], + /// prefix: "https://greenmarket.example/r/", + /// hint: "Point at the receipt's QR code", + /// }); + /// assert(result.isOk(), "scanner.scan failed:", result); + /// const outcome = result.value.outcome; + /// switch (outcome.tag) { + /// case "Scanned": + /// console.log("scanned:", outcome.value.format, outcome.value.text); + /// break; + /// case "Dismissed": + /// console.log("the user closed the viewfinder; worth offering again"); + /// break; + /// } + /// ``` + #[wire(id = 0)] + async fn scan( + &self, + _cx: &CallContext, + _request: HostScannerScanRequest, + ) -> Result> { + Err(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index e2cb662d0d..817ca4802f 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -76,9 +76,9 @@ pub mod latest { BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, - ChatRoomRegistrationStatus, ColorToken, ColumnProps, ContactHandle, ContactPickOutcome, - ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, EffectProps, - GenericError, HorizontalAlignment, HostAccountCreateProofRequest, + ChatRoomRegistrationStatus, CodeFormat, ColorToken, ColumnProps, ContactHandle, + ContactPickOutcome, ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, + EffectProps, GenericError, HorizontalAlignment, HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, HostAccountSignVrfError, HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData, @@ -88,7 +88,7 @@ pub mod latest { RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, RingLocationJunction, RingVrfKeyDisclosure, RowProps, - RuntimeApi, RuntimeSpec, RuntimeType, Shape, SignedStatement, Size, Statement, + RuntimeApi, RuntimeSpec, RuntimeType, ScanOutcome, Shape, SignedStatement, Size, Statement, StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, @@ -136,6 +136,12 @@ pub mod latest { pub type HostContactsPickResponse = LatestOf; /// Contact picker failure. pub type HostContactsPickError = LatestOf; + /// Scanner request. + pub type HostScannerScanRequest = LatestOf; + /// Scanner outcome. + pub type HostScannerScanResponse = LatestOf; + /// Scanner failure. + pub type HostScannerScanError = LatestOf; /// Contextual alias derivation result. pub type HostAccountGetAliasResponse = LatestOf; diff --git a/rust/crates/truapi/src/runtime/capabilities.rs b/rust/crates/truapi/src/runtime/capabilities.rs index fd019b2a0d..5fb90a51c4 100644 --- a/rust/crates/truapi/src/runtime/capabilities.rs +++ b/rust/crates/truapi/src/runtime/capabilities.rs @@ -7,4 +7,5 @@ mod payment; mod platform; mod preimage; mod resources; +mod scanner; mod signing; diff --git a/rust/crates/truapi/src/runtime/capabilities/scanner.rs b/rust/crates/truapi/src/runtime/capabilities/scanner.rs new file mode 100644 index 0000000000..139e07b3b7 --- /dev/null +++ b/rust/crates/truapi/src/runtime/capabilities/scanner.rs @@ -0,0 +1,22 @@ +//! Product-facing scanner capability adapter. + +use tracing::instrument; +use truapi::api::Scanner; +use truapi::versioned::scanner::{ + HostScannerScanError, HostScannerScanRequest, HostScannerScanResponse, +}; +use truapi::{CallContext, CallError}; + +use crate::runtime::ProductRuntimeHost; + +#[truapi::async_trait] +impl Scanner for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "scanner.scan"))] + async fn scan( + &self, + _cx: &CallContext, + _request: HostScannerScanRequest, + ) -> Result> { + Err(CallError::Unsupported) + } +} diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 24d2204ebd..6e61926133 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -9,7 +9,7 @@ use crate::platform::{ use parity_scale_codec::Encode; use truapi::api::{ Account, Chain, Entropy, Game, LocalStorage, Notifications, Permissions, Preimage, - ResourceAllocation, Signing, StatementStore, System, Theme, Worker, + ResourceAllocation, Scanner, Signing, StatementStore, System, Theme, Worker, }; use truapi::v02; use truapi::versioned::account::{ @@ -28,6 +28,7 @@ use truapi::versioned::chain::{ use truapi::versioned::entropy::{ HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, }; +use truapi::versioned::scanner::HostScannerScanRequest; use truapi::versioned::game::{ HostCancelNextGameError, HostCancelNextGameRequest, HostCancelNextGameResponse, HostRemindNextGameError, HostRemindNextGameRequest, HostRemindNextGameResponse, @@ -7682,3 +7683,22 @@ fn an_internal_cancellation_never_becomes_the_cancelled_variant() { ); } } + +/// A product tells "no scanner here" by `Unsupported` and falls back to its own +/// camera code; a `HostFailure` would read as a real failure. +#[test] +fn scanner_scan_is_unsupported_until_a_host_implements_it() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + + let result = futures::executor::block_on(Scanner::scan( + &host, + &CallContext::default(), + HostScannerScanRequest::V1(v01::HostScannerScanRequest { + formats: vec![v01::CodeFormat::Qr], + prefix: None, + hint: None, + }), + )); + + assert!(matches!(result, Err(CallError::Unsupported))); +} diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index ad8fff78bd..af4e873a38 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -17,6 +17,7 @@ mod pocket; mod preimage; mod renderer; mod resource_allocation; +mod scanner; mod signing; mod statement_store; mod system; @@ -41,6 +42,7 @@ pub use pocket::*; pub use preimage::*; pub use renderer::*; pub use resource_allocation::*; +pub use scanner::*; pub use signing::*; pub use statement_store::*; pub use system::*; diff --git a/rust/crates/truapi/src/v01/scanner.rs b/rust/crates/truapi/src/v01/scanner.rs new file mode 100644 index 0000000000..c35d2b2787 --- /dev/null +++ b/rust/crates/truapi/src/v01/scanner.rs @@ -0,0 +1,96 @@ +use parity_scale_codec::{Decode, Encode}; + +/// Code formats the host scanner reads: the set both platform decoders share. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum CodeFormat { + /// QR code. + Qr, + /// Aztec code. + Aztec, + /// Data Matrix code. + DataMatrix, + /// PDF417 code. + Pdf417, + /// EAN-13. A UPC-A code arrives as EAN-13 with a leading zero. + Ean13, + /// EAN-8. + Ean8, + /// UPC-E. + UpcE, + /// Code 128. + Code128, + /// Code 39. + Code39, + /// Code 93. + Code93, + /// Interleaved 2 of 5, including ITF-14. + Itf, +} + +/// Request to open the host's scanner. +/// +/// The host draws the viewfinder and writes its title, naming the product. +/// Only `hint` is product text, shown as one plain line under the title. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostScannerScanRequest { + /// Formats the product accepts. At least one. + pub formats: Vec, + /// Exact, case-sensitive start the text must have. At most 256 bytes. + pub prefix: Option, + /// What to point the camera at. At most 80 characters, one line. + pub hint: Option, +} + +/// How a scan ended. +/// +/// A dismissal is an outcome rather than an error, because it is worth +/// offering again. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum ScanOutcome { + /// The user scanned a code the request accepts. + Scanned { + /// The code's content, as the platform decoder reports it. + text: String, + /// The code's format. + format: CodeFormat, + }, + /// The user closed the viewfinder without scanning. + Dismissed, +} + +/// Outcome of a scan. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostScannerScanResponse { + /// How the scan ended. + pub outcome: ScanOutcome, +} + +/// Error returned by the scanner. +/// +/// A host with no scanner answers `Unsupported` at the framework level rather +/// than through this enum. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostScannerScanError { + /// The device has no camera, or the user refused the host application one. + CameraUnavailable, + /// Another scan is open. + Busy, + /// The request breaks a limit, so no viewfinder was shown. + InvalidRequest { + /// Which limit. + reason: String, + }, + /// 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 541fba0480..03af42b351 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -50,6 +50,7 @@ pub mod pocket; pub mod preimage; pub mod renderer; pub mod resource_allocation; +pub mod scanner; pub mod signing; pub mod statement_store; pub mod system; diff --git a/rust/crates/truapi/src/versioned/scanner.rs b/rust/crates/truapi/src/versioned/scanner.rs new file mode 100644 index 0000000000..65c4300f3e --- /dev/null +++ b/rust/crates/truapi/src/versioned/scanner.rs @@ -0,0 +1,9 @@ +//! Versioned wrappers for [`Scanner`](crate::api::Scanner) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostScannerScanRequest { V1 => v01::HostScannerScanRequest } + pub enum HostScannerScanResponse { V1 => v01::HostScannerScanResponse } + pub enum HostScannerScanError { V1 => v01::HostScannerScanError } +} From d8247f4baa2eeddf7536305f77cf9cf8b8c85d00 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 11:53:44 -0300 Subject: [PATCH 02/29] docs(rfc): tighten the scanner request rules after review - Add Codabar, which both platform decoders read. - Compare the prefix ignoring ASCII case, since QR codes often store URLs in capitals. - State the units of each limit, and refuse hints with line separators or direction-changing characters. - Say hosts report UPC-A as EAN-13 before filtering, and that a refused host answer reaches the product as Unknown. - Drop the UniFFI derives until a native caller needs them. - Bump @parity/truapi-host too, since its runtime now answers the method. - Ask in the RFC whether background executions may open the viewfinder. --- .changeset/host-scanner.md | 3 +- docs/rfcs/host-scanner.md | 126 ++++++++++-------- .../truapi-codegen/tests/golden/wire_table.rs | 2 +- rust/crates/truapi/src/runtime/tests.rs | 2 +- rust/crates/truapi/src/v01/scanner.rs | 24 ++-- 5 files changed, 90 insertions(+), 67 deletions(-) diff --git a/.changeset/host-scanner.md b/.changeset/host-scanner.md index 5b384cdb32..e6d89abfc3 100644 --- a/.changeset/host-scanner.md +++ b/.changeset/host-scanner.md @@ -1,5 +1,6 @@ --- "@parity/truapi": minor +"@parity/truapi-host": minor --- -Adds the `scanner` service. `scan` asks the host to open its own QR and barcode viewfinder and answers with the scanned code's text and format. Hosts answer `Unsupported` until they serve it. +Add the `scanner` service. `scan` asks the host to open its own QR and barcode viewfinder and returns the scanned code's text and format. No host serves it yet, so the runtime answers `Unsupported`. diff --git a/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md index 762cdb9fd5..7b10307fd7 100644 --- a/docs/rfcs/host-scanner.md +++ b/docs/rfcs/host-scanner.md @@ -8,26 +8,26 @@ status: draft ## Summary -A product asks the host to open its native QR and barcode viewfinder. The host draws the viewfinder, the user points it -at a code, and the product receives that code's text and format. The product never receives camera frames, needs no -camera permission, and the host does not act on what was scanned. +A product asks the host to open its own QR and barcode viewfinder. The user points the camera at a code, and the product +gets back the code's text and format. The product never sees the camera, needs no camera permission, and the host does +not act on what was scanned. ## Motivation -Every product that scans a code today builds its own viewfinder, so each one looks and behaves differently while both -native hosts already ship a polished scanner of their own. Scanning a receipt, a ticket or an address should feel the -same in every product. +Today every product that scans a code builds its own viewfinder. Each one looks and behaves differently, even though +both native hosts already ship a good scanner. Scanning a receipt, a ticket or an address should feel the same in every +product. -A Worker execution has no camera at all, so a Pocket card cannot offer to scan anything. A page execution can call -`getUserMedia` after a `Camera` grant, but it then ships its own decoder and viewfinder, and it receives the whole video -stream for a task that needs one string. +A Worker has no camera at all, so a Pocket card cannot offer to scan anything. A page can call `getUserMedia` after a +`Camera` grant, but then it has to ship its own decoder and viewfinder, and it receives the whole video stream when it +only needs one string. ## Approach -A new `Scanner` trait has one method. The product names the formats it accepts and, optionally, a prefix the text must -start with and a one-line hint. The host opens its viewfinder over whatever is on screen and closes it on the first code -that passes the filter. Codes that do not pass are ignored: the viewfinder stays open and the host briefly says the code -is not for this product. +A new `Scanner` trait has one method, `scan`. The product says which formats it accepts. It can also give a prefix the +text must start with, and a one-line hint. The host opens its viewfinder and closes it on the first code that passes. +Codes that do not pass are ignored. The viewfinder stays open and the host briefly says the code is not for this +product. ```ts const result = await truapi.scanner.scan({ @@ -42,7 +42,7 @@ switch (outcome.tag) { console.log(outcome.value.format, outcome.value.text); break; case "Dismissed": - console.log("the user closed the viewfinder; worth offering again"); + console.log("the user closed the viewfinder, worth offering again"); break; } ``` @@ -53,15 +53,15 @@ pub enum CodeFormat { Qr, Aztec, DataMatrix, Pdf417, /// Also UPC-A, reported with a leading zero. Ean13, - Ean8, UpcE, Code128, Code39, Code93, Itf, + Ean8, UpcE, Code128, Code39, Code93, Itf, Codabar, } pub struct HostScannerScanRequest { /// Formats the product accepts. At least one. pub formats: Vec, - /// Exact, case-sensitive start the text must have. At most 256 bytes. + /// Start the text must have, ignoring ASCII letter case. At most 256 bytes of UTF-8. pub prefix: Option, - /// One line of plain text shown under the host's title. At most 80 characters. + /// One line shown under the host's title. At most 80 Unicode scalar values. pub hint: Option, } @@ -79,7 +79,7 @@ pub enum HostScannerScanError { CameraUnavailable, /// Another scan is already open. Busy, - /// No formats, or a prefix or hint over its limit. + /// No formats, or a prefix or hint that breaks its limit. InvalidRequest { reason: String }, Unknown { reason: String }, } @@ -92,45 +92,67 @@ pub trait Scanner { } ``` -`Dismissed` is worth offering again, `CameraUnavailable` is not until the user changes a setting, and `Unsupported` -(a host with no scanner) never is on that host. One code per call: a product that scans several calls again. -Cancelling the call closes the viewfinder ([Request cancellation](request-cancellation.md)). +What each answer means for the product: -**The host owns the viewfinder.** It writes the title itself, naming the requesting product by its id ("Scan for -greenmarket.dot"), and shows the product's hint below it as plain text. A product cannot draw over the viewfinder or -title it, so it cannot dress up a request as a host instruction such as "scan to sign in". +| Answer | What the product does next | +| --- | --- | +| `Scanned` | Uses the text and format. | +| `Dismissed` | Can offer the scan again. | +| `CameraUnavailable` | Stops offering it until the user changes a setting. | +| `Busy` | Waits for the other scan to end. | +| `InvalidRequest` | Fixes the call. Nothing was shown. | +| `Unsupported` | Uses its own camera code. This host will never have a scanner. | -**The host does not act on what it scanned.** The host's own scanner treats some codes as deep links: a link to another -product opens it, a pairing link starts sign-in, a payment link opens a payment screen. A product's scan skips all of -that and returns the text. A product that wants to follow a scanned link calls `navigate_to`, under the rules that call -already has. +One call scans one code. A product that wants several calls again. Cancelling the call closes the viewfinder +([Request cancellation](request-cancellation.md)). -**No permission.** The product never touches the camera, and the user pointing the host's viewfinder at a code is the -consent. The host application's own OS camera prompt still applies and is shown inside the host's flow. The `Camera` -permission and `getUserMedia` are unchanged: a product that needs the camera for anything else, or wants its own -scanning UI, keeps doing so. +**The host owns the viewfinder.** The host writes the title itself and names the product by its id, for example "Scan +for greenmarket.dot". The product cannot draw over the viewfinder or change the title. The hint is shown below the +title as the product's own words, so the user can tell it apart from host text. To keep it to one plain line, a hint +may not contain control characters, line or paragraph separators, or characters that change text direction. -**The core checks both sides.** It refuses an invalid request before any viewfinder is raised, allows one open scan per -host, and refuses a host answer whose format the product did not accept or whose text lacks the prefix. The native hosts -filter codes in the viewfinder with the same rule, exported from the core, so iOS and Android accept exactly the same -codes. Scanning needs no signed-in session. +**The host does not act on what it scanned.** The host's own scanner treats some codes as links. A link to another +product opens it, a pairing link starts sign-in, and a payment link opens a payment screen. A product's scan skips all +of that and returns the text. If the product wants to follow a scanned link, it calls `navigate_to`, which keeps its own +rules. -**Hosts.** iOS and Android draw the viewfinder with the platform decoders they already use (AVFoundation and ML Kit), -whose common formats are the list above. A web host answers `Unsupported`. The headless CLI host and the test host -answer with a code the test supplies, so a product can test its scan flow without a camera. +**No permission.** The product never touches the camera, and the user pointing the viewfinder at a code is the consent. +The OS still asks the host application for camera access the first time. The `Camera` permission and `getUserMedia` do +not change. A product that needs the camera for anything else, or wants its own scanning screen, keeps using them. + +**The core checks both sides.** Before any viewfinder opens, the core refuses an invalid request and allows only one +open scan per host. When the host answers, the core checks the code again. If its format was not asked for, or its text +lacks the prefix, the product gets `Unknown`, never the text. The native hosts filter codes in the viewfinder with the +same rule, taken from the core. A host reports a UPC-A code as EAN-13 with a leading zero before it checks the rule, so +iOS and Android accept the same codes. Scanning does not need a signed-in session. + +**Text only.** The product gets the code's content as text. A code whose content the platform cannot read as text is +skipped, the same way as a code that fails the filter. + +**Hosts.** iOS and Android draw the viewfinder with the decoders they already use, AVFoundation and ML Kit. The format +list is what both read. A web host answers `Unsupported`. The headless CLI host and the test host answer with a code the +test supplies, so a product can test its scan flow without a camera. ## Trade-offs -- Text only. A code whose content is not valid text is skipped like one that fails the filter. Raw bytes can be added - later as an optional field without breaking existing callers. -- A prefix is the only content filter. A product that needs finer checks validates the text itself and calls again. -- Any execution can call it, including a Worker the user is not looking at. The title always names the product and the - user can close it, which is the exposure the contact picker already has. -- Web hosts have no scanner yet, so a product keeps its own `getUserMedia` fallback there. A bundled decoder can be - added later without changing this API. -- The host's main scanner still cannot hand a code to a product. A product claiming codes it handles is a separate - feature. -- Dropped: a product-written title (lets a product pose as the host); a regular-expression filter (iOS, Android and - browsers disagree on regex syntax); a live accept/reject exchange per code (more protocol for a need no product has - yet); the host routing scanned deep links (a product's scan would leave the product); a `Camera` grant on `scan` - (the product never receives camera data). +- A prefix is the only content filter. A product that needs finer checks reads the text itself and calls again. +- The prefix ignores ASCII letter case, because QR codes often store URLs in capitals. A product that cares about case + checks the text itself. +- Raw bytes are not returned. They can be added later as an optional field without breaking existing callers. +- Web hosts have no scanner yet, so a product keeps its own `getUserMedia` fallback there. A bundled decoder can come + later without changing this API. +- The host's main scanner still cannot hand a code to a product. Letting a product claim the codes it handles is a + separate feature. +- Considered and dropped: + - A title written by the product. It would let a product look like the host. + - A regular expression filter. iOS, Android and browsers read regular expressions differently. + - Accepting or rejecting each code live. It needs a two-way exchange that no product needs yet. + - The host following scanned links. The user would leave the product mid-scan. + - A `Camera` grant on `scan`. The product never receives camera data. + +## Open questions + +Any execution can call `scan`, including a Worker behind a Pocket card the user is not looking at. The title always +names the product and the user can close the viewfinder, which matches the contact picker today. But a product could +open the viewfinder again every time the user closes it, and while it is open other products get `Busy`. Should the +host open the viewfinder only for a product that is on screen, or only right after the user taps something in it? diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 7b562cc976..1789be6648 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 = "37f7502eac439ff0"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "9ea91bca72d5e658"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 6e61926133..eb32e53986 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -28,7 +28,6 @@ use truapi::versioned::chain::{ use truapi::versioned::entropy::{ HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, }; -use truapi::versioned::scanner::HostScannerScanRequest; use truapi::versioned::game::{ HostCancelNextGameError, HostCancelNextGameRequest, HostCancelNextGameResponse, HostRemindNextGameError, HostRemindNextGameRequest, HostRemindNextGameResponse, @@ -51,6 +50,7 @@ use truapi::versioned::resource_allocation::{ HostRequestResourceAllocationError, HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, }; +use truapi::versioned::scanner::HostScannerScanRequest; use truapi::versioned::signing::{ HostCreateTransactionError, HostCreateTransactionRequest, HostCreateTransactionResponse, HostCreateTransactionWithLegacyAccountError, HostCreateTransactionWithLegacyAccountRequest, diff --git a/rust/crates/truapi/src/v01/scanner.rs b/rust/crates/truapi/src/v01/scanner.rs index c35d2b2787..f01132c686 100644 --- a/rust/crates/truapi/src/v01/scanner.rs +++ b/rust/crates/truapi/src/v01/scanner.rs @@ -2,10 +2,6 @@ use parity_scale_codec::{Decode, Encode}; /// Code formats the host scanner reads: the set both platform decoders share. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Enum) -)] pub enum CodeFormat { /// QR code. Qr, @@ -15,7 +11,8 @@ pub enum CodeFormat { DataMatrix, /// PDF417 code. Pdf417, - /// EAN-13. A UPC-A code arrives as EAN-13 with a leading zero. + /// EAN-13. Hosts report a UPC-A code as EAN-13 with a leading zero, before + /// matching it against the request. Ean13, /// EAN-8. Ean8, @@ -29,6 +26,8 @@ pub enum CodeFormat { Code93, /// Interleaved 2 of 5, including ITF-14. Itf, + /// Codabar. + Codabar, } /// Request to open the host's scanner. @@ -36,16 +35,16 @@ pub enum CodeFormat { /// The host draws the viewfinder and writes its title, naming the product. /// Only `hint` is product text, shown as one plain line under the title. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] pub struct HostScannerScanRequest { /// Formats the product accepts. At least one. pub formats: Vec, - /// Exact, case-sensitive start the text must have. At most 256 bytes. + /// Start the text must have, compared ignoring ASCII letter case, since QR + /// codes often carry URLs in capitals. At most 256 bytes of UTF-8. pub prefix: Option, - /// What to point the camera at. At most 80 characters, one line. + /// What to point the camera at, shown as the product's words. At most 80 + /// Unicode scalar values (`[...hint].length` in TypeScript). No control + /// characters, line or paragraph separators, or bidirectional formatting + /// characters. pub hint: Option, } @@ -57,7 +56,8 @@ pub struct HostScannerScanRequest { pub enum ScanOutcome { /// The user scanned a code the request accepts. Scanned { - /// The code's content, as the platform decoder reports it. + /// The code's content as text. Codes whose content is not text are + /// skipped while scanning. text: String, /// The code's format. format: CodeFormat, From c5c23f07c0872278d0090ab8c19af56e49d3469c Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:33:33 -0300 Subject: [PATCH 03/29] feat(truapi): check scanner requests and filter scanned codes in the core Request limits, the prefix and format match, and ScanFilter, which both native hosts call for every code the camera reads so they accept the same codes and show the same messages. --- rust/crates/truapi/src/host_logic.rs | 1 + rust/crates/truapi/src/host_logic/scanner.rs | 242 +++++++++++++++++++ rust/crates/truapi/src/v01/scanner.rs | 8 + 3 files changed, 251 insertions(+) create mode 100644 rust/crates/truapi/src/host_logic/scanner.rs diff --git a/rust/crates/truapi/src/host_logic.rs b/rust/crates/truapi/src/host_logic.rs index dfb799dbe0..90c811f0c2 100644 --- a/rust/crates/truapi/src/host_logic.rs +++ b/rust/crates/truapi/src/host_logic.rs @@ -15,6 +15,7 @@ pub mod entropy; pub mod features; pub mod product_account; pub mod raw_signing; +pub mod scanner; pub mod session; pub mod session_store; pub mod sso; diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs new file mode 100644 index 0000000000..2de54c2b77 --- /dev/null +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -0,0 +1,242 @@ +//! What a product may ask the host scanner, and which scanned codes answer it. +//! +//! The core checks requests and host answers with these rules, and native hosts +//! filter codes in the viewfinder with [`ScanFilter`], so every host accepts +//! the same codes. + +use std::sync::Mutex; + +use truapi::latest::{CodeFormat, HostScannerScanRequest}; + +/// Longest prefix a product may require, in bytes of UTF-8. +pub const MAX_PREFIX_BYTES: usize = 256; +/// Longest hint a product may show, in Unicode scalar values. +pub const MAX_HINT_CHARS: usize = 80; + +/// Why `request` cannot be shown, if it cannot. +pub fn validate_request(request: &HostScannerScanRequest) -> Result<(), String> { + if request.formats.is_empty() { + return Err("formats is empty".into()); + } + if let Some(prefix) = &request.prefix + && prefix.len() > MAX_PREFIX_BYTES + { + return Err(format!("prefix is longer than {MAX_PREFIX_BYTES} bytes")); + } + if let Some(hint) = &request.hint { + if hint.chars().count() > MAX_HINT_CHARS { + return Err(format!("hint is longer than {MAX_HINT_CHARS} characters")); + } + if hint.chars().any(breaks_the_line) { + return Err("hint must be one plain line".into()); + } + } + Ok(()) +} + +/// Characters that would let a hint break out of its line or flip its +/// direction under the host's title. +fn breaks_the_line(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{2028}' + | '\u{2029}' + | '\u{200E}' + | '\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2066}'..='\u{2069}' + ) +} + +/// Whether a code of `format` reading `text` answers `request`. +pub fn accepts(request: &HostScannerScanRequest, format: CodeFormat, text: &str) -> bool { + request.formats.contains(&format) + && request.prefix.as_deref().is_none_or(|prefix| { + text.get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) + }) +} + +/// What a host does with one code the camera read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] +pub enum ScanVerdict { + /// Close the viewfinder and answer with this code. + Accept, + /// Briefly tell the user this code is not for the product, and keep scanning. + NotForThisProduct, + /// Do nothing and keep scanning. + Ignore, +} + +/// Decides, code by code, what the viewfinder does for one scan. +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))] +pub struct ScanFilter { + request: HostScannerScanRequest, + state: Mutex, +} + +#[derive(Default)] +struct FilterState { + accepted: bool, + last_refused: Option<(CodeFormat, String)>, +} + +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export)] +impl ScanFilter { + /// A filter for one scan of `request`. + #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)] + pub fn new(request: HostScannerScanRequest) -> Self { + Self { + request, + state: Mutex::default(), + } + } + + /// What to do with a code the camera read. + pub fn observe(&self, format: CodeFormat, text: String) -> ScanVerdict { + let mut state = self.state.lock().expect("scan filter mutex poisoned"); + if state.accepted { + return ScanVerdict::Ignore; + } + if accepts(&self.request, format, &text) { + state.accepted = true; + return ScanVerdict::Accept; + } + let code = Some((format, text)); + if state.last_refused == code { + return ScanVerdict::Ignore; + } + state.last_refused = code; + ScanVerdict::NotForThisProduct + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request( + formats: Vec, + prefix: Option<&str>, + hint: Option<&str>, + ) -> HostScannerScanRequest { + HostScannerScanRequest { + formats, + prefix: prefix.map(str::to_owned), + hint: hint.map(str::to_owned), + } + } + + fn qr(prefix: Option<&str>, hint: Option<&str>) -> HostScannerScanRequest { + request(vec![CodeFormat::Qr], prefix, hint) + } + + fn receipts() -> HostScannerScanRequest { + qr(Some("https://greenmarket.example/r/"), None) + } + + #[test] + fn a_request_must_name_a_format() { + // With no formats the host, not the product, would decide what comes back. + assert!(validate_request(&request(vec![], None, None)).is_err()); + assert!(validate_request(&qr(None, None)).is_ok()); + } + + #[test] + fn the_hint_is_counted_in_unicode_scalars() { + // TypeScript products count with `[...hint].length`, which agrees with this. + assert!(validate_request(&qr(None, Some(&"é".repeat(MAX_HINT_CHARS)))).is_ok()); + assert!(validate_request(&qr(None, Some(&"a".repeat(MAX_HINT_CHARS + 1)))).is_err()); + } + + #[test] + fn the_hint_stays_one_plain_line() { + // The hint sits under the host's title, so text that breaks the line or + // flips its direction could pass for a host instruction. + for refused in [ + "scan\nto sign in", + "tab\there", + "line\u{2028}separator", + "para\u{2029}separator", + "\u{202E}reversed", + "\u{2066}isolate", + "\u{200F}mark", + ] { + assert!( + validate_request(&qr(None, Some(refused))).is_err(), + "{refused:?} was allowed" + ); + } + assert!(validate_request(&qr(None, Some("Point at the receipt's QR code"))).is_ok()); + } + + #[test] + fn the_prefix_is_counted_in_bytes() { + assert!(validate_request(&qr(Some(&"a".repeat(MAX_PREFIX_BYTES)), None)).is_ok()); + let over = "é".repeat(MAX_PREFIX_BYTES / 2 + 1); + assert!(validate_request(&qr(Some(&over), None)).is_err()); + } + + #[test] + fn a_code_is_accepted_only_in_a_named_format() { + assert!(accepts(&qr(None, None), CodeFormat::Qr, "anything")); + assert!(!accepts(&qr(None, None), CodeFormat::Ean13, "4006381333931")); + } + + #[test] + fn the_prefix_ignores_letter_case_and_nothing_else() { + // QR codes often store URLs in capitals to fit more in the code. + assert!(accepts(&receipts(), CodeFormat::Qr, "https://greenmarket.example/r/BAG6")); + assert!(accepts(&receipts(), CodeFormat::Qr, "HTTPS://GREENMARKET.EXAMPLE/R/BAG6")); + assert!(!accepts(&receipts(), CodeFormat::Qr, "polkadotapp://pair?handshake=00")); + assert!(!accepts(&receipts(), CodeFormat::Qr, "https://greenmarket.example")); + } + + #[test] + fn a_prefix_that_ends_inside_a_character_does_not_panic() { + // A byte-length cut through a multi-byte character must refuse, not crash. + assert!(!accepts(&qr(Some("ab"), None), CodeFormat::Qr, "aé")); + } + + #[test] + fn the_filter_accepts_once() { + // The viewfinder sees the same code on many frames before it closes. + let filter = ScanFilter::new(receipts()); + let receipt = "https://greenmarket.example/r/BAG6".to_owned(); + assert_eq!(filter.observe(CodeFormat::Qr, receipt.clone()), ScanVerdict::Accept); + assert_eq!(filter.observe(CodeFormat::Qr, receipt), ScanVerdict::Ignore); + } + + #[test] + fn the_filter_names_a_wrong_code_once_while_it_stays_in_view() { + // One message per wrong code, not one per camera frame. + let filter = ScanFilter::new(receipts()); + let poster = "https://elsewhere.example".to_owned(); + let pairing = "polkadotapp://pair?handshake=00".to_owned(); + assert_eq!( + filter.observe(CodeFormat::Qr, poster.clone()), + ScanVerdict::NotForThisProduct + ); + assert_eq!(filter.observe(CodeFormat::Qr, poster.clone()), ScanVerdict::Ignore); + assert_eq!( + filter.observe(CodeFormat::Qr, pairing), + ScanVerdict::NotForThisProduct + ); + assert_eq!( + filter.observe(CodeFormat::Qr, poster), + ScanVerdict::NotForThisProduct + ); + } + + #[test] + fn the_filter_still_accepts_after_refusing() { + let filter = ScanFilter::new(receipts()); + filter.observe(CodeFormat::Qr, "https://elsewhere.example".to_owned()); + assert_eq!( + filter.observe(CodeFormat::Qr, "https://greenmarket.example/r/1".to_owned()), + ScanVerdict::Accept + ); + } +} diff --git a/rust/crates/truapi/src/v01/scanner.rs b/rust/crates/truapi/src/v01/scanner.rs index f01132c686..9a9ee47671 100644 --- a/rust/crates/truapi/src/v01/scanner.rs +++ b/rust/crates/truapi/src/v01/scanner.rs @@ -2,6 +2,10 @@ use parity_scale_codec::{Decode, Encode}; /// Code formats the host scanner reads: the set both platform decoders share. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum CodeFormat { /// QR code. Qr, @@ -35,6 +39,10 @@ pub enum CodeFormat { /// The host draws the viewfinder and writes its title, naming the product. /// Only `hint` is product text, shown as one plain line under the title. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostScannerScanRequest { /// Formats the product accepts. At least one. pub formats: Vec, From e829b3940deb979e062cd358d0f8b90842a2f7e0 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:33:33 -0300 Subject: [PATCH 04/29] feat(truapi): open the host scanner for a product, one scan at a time Adds ScannerPlatform as an optional host capability. The core refuses an invalid request before any viewfinder opens, allows one open scan per host, closes the viewfinder when the product cancels, and checks the host's answer again before the product sees it. --- .../tests/golden/host-callbacks-adapter.ts | 15 + .../tests/golden/host-callbacks.ts | 59 ++++ .../tests/golden/wasm_bridge.rs | 31 +++ .../tests/golden/worker-callbacks.ts | 16 ++ rust/crates/truapi/src/host_core.rs | 21 ++ rust/crates/truapi/src/platform.rs | 55 +++- rust/crates/truapi/src/runtime.rs | 2 +- .../src/runtime/capabilities/scanner.rs | 54 +++- rust/crates/truapi/src/runtime/services.rs | 42 ++- rust/crates/truapi/src/runtime/tests.rs | 256 +++++++++++++++++- 10 files changed, 530 insertions(+), 21 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 6cd0695bed..1c1a5c88b9 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -22,6 +22,7 @@ import { HostPocketRemoveCardRequest, HostPushNotificationRequest, HostPushNotificationResponse, + HostScannerScanRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, RemotePermissionRequest, @@ -35,6 +36,7 @@ import { HostContactLookup, HostContactMatches, HostContactPick, + HostScan, PermissionDecision, ProductContext, UserConfirmationReview, @@ -115,6 +117,7 @@ export interface RawCallbacks { sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void, ): (() => void) | void; + scanCode?(product: Uint8Array, request: Uint8Array): Promise; subscribeTheme( sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void, @@ -132,6 +135,7 @@ export function createWasmRawCallbacks( const game = callbacks.game; const permissionStatus = callbacks.permissionStatus; const pocket = callbacks.pocket; + const scanner = callbacks.scanner; return { authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), @@ -289,6 +293,17 @@ export function createWasmRawCallbacks( (item) => sendItem(HostLocalStorageChangeItem.enc(item)), sendError, ), + ...(scanner + ? { + scanCode: async (product, request) => + HostScan.enc( + await scanner.scanCode( + ProductContext.dec(product), + HostScannerScanRequest.dec(request), + ), + ), + } + : {}), subscribeTheme: (sendItem, sendError) => driveResultStream( callbacks.theme.subscribeTheme(), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index f01630d783..a7b1fd46db 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -10,6 +10,7 @@ import { AllocatableResource, Bytes32, ChainIdentifier, + CodeFormat, HostAccountSignVrfRequest, HostDevicePermissionRequest, HostSignPayloadRequest, @@ -41,6 +42,7 @@ import type { HostPocketRemoveCardRequest, HostPushNotificationRequest, HostPushNotificationResponse, + HostScannerScanRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, Result, @@ -352,6 +354,24 @@ export type HostContactPick = */ | { tag: "Unsupported"; value?: undefined }; +/** + * How the host's scanner ended. + */ +export type HostScan = + /** + * The user scanned a code the request accepts. + */ + | { tag: "Scanned"; value: { text: string; format: CodeFormat } } + /** + * The user closed the viewfinder without scanning. + */ + | { tag: "Dismissed"; value?: undefined } + /** + * The device has no camera, or the user refused the host application one. + * The host has already told the user how to turn it on. + */ + | { tag: "CameraUnavailable"; value?: undefined }; + /** * Review shown before a product learns the user's primary identity. */ @@ -868,6 +888,21 @@ export const HostContactPick: S.Codec = S.lazy( }), ); +/** + * How the host's scanner ended. + */ +export const HostScan: S.Codec = S.lazy( + (): S.Codec => + S.TaggedUnion({ + Scanned: S.Struct({ text: S.str, format: CodeFormat }) as S.Codec<{ + text: string; + format: CodeFormat; + }>, + Dismissed: S._void, + CameraUnavailable: S._void, + }), +); + /** * Review shown before a product learns the user's primary identity. */ @@ -1666,6 +1701,28 @@ export interface ProductStorage { ): AsyncIterable>; } +/** + * Host-owned viewfinder for QR codes and barcodes. + * + * Optional, and listed on `OptionalPlatform` as `ContactsPlatform` is. + * + * The host draws the viewfinder and titles it with the requesting product's + * id. Only `request.hint` is product text. It hands every code the camera + * reads to the core's `ScanFilter` and acts on its verdict, and never follows + * a scanned link itself. The core drops + * the returned future when the product cancels, and the host closes the + * viewfinder then. + */ +export interface ScannerPlatform { + /** + * Open the viewfinder on behalf of `product` and wait for the user. + */ + scanCode( + product: ProductContext, + request: HostScannerScanRequest, + ): Promise; +} + /** * Host theme source. */ @@ -1722,6 +1779,7 @@ export interface HostCallbacks { game?: GamePlatform; permissionStatus?: PermissionStatusHost; pocket?: PocketPlatform; + scanner?: ScannerPlatform; } export interface RequiredHostCallbacks { @@ -1743,4 +1801,5 @@ export interface RequiredHostCallbacks { game?: Required; permissionStatus?: Required; pocket?: Required; + scanner?: Required; } diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index f30c584664..5ca154bc2b 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -57,6 +57,7 @@ pub struct JsBridge { pub write: Function, pub clear: Function, pub subscribe_storage: Function, + pub scan_code: Function, pub subscribe_theme: Function, pub confirm_permission: Function, pub confirm_user_action: Function, @@ -65,6 +66,7 @@ pub struct JsBridge { pub game_present: bool, pub permission_status_present: bool, pub pocket_present: bool, + pub scanner_present: bool, } impl JsBridge { @@ -112,6 +114,8 @@ impl JsBridge { write: get_function(callbacks, "write")?, clear: get_function(callbacks, "clear")?, subscribe_storage: get_function(callbacks, "subscribeStorage")?, + scan_code: get_optional_function(callbacks, "scanCode")? + .unwrap_or_else(|| missing_callback("scanCode")), subscribe_theme: get_function(callbacks, "subscribeTheme")?, confirm_permission: get_function(callbacks, "confirmPermission")?, confirm_user_action: get_function(callbacks, "confirmUserAction")?, @@ -127,6 +131,7 @@ impl JsBridge { .is_some(), pocket_present: get_optional_function(callbacks, "subscribePocketCards")?.is_some() && get_optional_function(callbacks, "removePocketCard")?.is_some(), + scanner_present: get_optional_function(callbacks, "scanCode")?.is_some(), }) } @@ -154,6 +159,11 @@ impl JsBridge { pub fn has_pocket(&self) -> bool { self.pocket_present } + + /// Whether the host supplied every `scanner` callback. + pub fn has_scanner(&self) -> bool { + self.scanner_present + } } impl crate::platform::AuthPresenter for WasmPlatform { @@ -631,6 +641,27 @@ impl crate::platform::ProductStorage for WasmPlatform { } } +#[crate::platform::async_trait] +impl crate::platform::ScannerPlatform for WasmPlatform { + async fn scan_code( + &self, + product: &crate::platform::ProductContext, + request: &v01::HostScannerScanRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.scan_code, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(request.encode().as_slice()).into(), + ], + ) + .await + .map_err(generic)?; + decode_bytes::(bytes, "scanCode response did not decode") + .map_err(generic) + } +} + impl crate::platform::ThemeHost for WasmPlatform { fn subscribe_theme( &self, diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 834b1d1d5d..90eea86f57 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -36,6 +36,7 @@ export const CALLBACK_NAMES = [ "read", "write", "clear", + "scanCode", "confirmPermission", "confirmUserAction", ] as const; @@ -284,6 +285,17 @@ function pocketRawCallbacks( }; } +function scannerRawCallbacks( + bridge: WorkerCallbackBridge, +): Required> { + return { + scanCode: (product, request) => + bridge.callbackRequest("scanCode", [product, request]) as ReturnType< + Required["scanCode"] + >, + }; +} + /** * Optional capabilities the main-thread host actually serves. A * capability left out here is not proxied into the worker, so the @@ -300,6 +312,8 @@ export interface OptionalCapabilities { permissionStatus?: boolean; /** Whether the host serves this capability. */ pocket?: boolean; + /** Whether the host serves this capability. */ + scanner?: boolean; } export function createWorkerRawCallbacks( @@ -318,6 +332,8 @@ export function createWorkerRawCallbacks( if (capabilities.permissionStatus) Object.assign(callbacks, permissionStatusRawCallbacks(bridge)); if (capabilities.pocket) Object.assign(callbacks, pocketRawCallbacks(bridge)); + if (capabilities.scanner) + Object.assign(callbacks, scannerRawCallbacks(bridge)); return callbacks; } diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 98ee4b6dfe..8fd8dd2629 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -16,6 +16,7 @@ use std::time::Duration; use crate::platform::{ ChatPlatform, ContactsPlatform, GamePlatform, PermissionStatusHost, PocketPlatform, + ScannerPlatform, }; use crate::platform::{ CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, @@ -278,6 +279,16 @@ impl PairingHostRuntime { self.services.install_game_platform(platform) } + /// Install the host's [`ScannerPlatform`], which draws the viewfinder. + /// + /// Set-once, so the viewfinder cannot change hands under a running product. + /// Returns whether this call installed it. Call it before serving any + /// product runtime. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_scanner_platform"))] + pub fn set_scanner_platform(&self, platform: Arc) -> bool { + self.services.install_scanner_platform(platform) + } + /// Install the host's [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// @@ -711,6 +722,16 @@ impl SigningHostRuntime { self.services.install_game_platform(platform) } + /// Install the host's [`ScannerPlatform`], which draws the viewfinder. + /// + /// Set-once, so the viewfinder cannot change hands under a running product. + /// Returns whether this call installed it. Call it before serving any + /// product runtime. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_scanner_platform"))] + pub fn set_scanner_platform(&self, platform: Arc) -> bool { + self.services.install_scanner_platform(platform) + } + /// Install the host's [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index d80c9b1426..7801028586 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -27,7 +27,7 @@ pub mod mock; use truapi::latest::{ AllocatableResource, ChainIdentifier, ChatAction, ChatActions, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, GenericError, + ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, CodeFormat, GenericError, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, @@ -35,6 +35,7 @@ use truapi::latest::{ HostFeatureSupportedResponse, HostLocalStorageChangeItem, HostLocaleSubscribeItem, HostNavigateToError, HostPlatform, HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPushNotificationRequest, HostPushNotificationResponse, + HostScannerScanRequest, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, HostWorkerOperationError, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, @@ -3492,6 +3493,44 @@ pub trait ContactsPlatform: Send + Sync { } } +/// How the host's scanner ended. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] +pub enum HostScan { + /// The user scanned a code the request accepts. + Scanned { + /// The code's content. + text: String, + /// The code's format. + format: CodeFormat, + }, + /// The user closed the viewfinder without scanning. + Dismissed, + /// The device has no camera, or the user refused the host application one. + /// The host has already told the user how to turn it on. + CameraUnavailable, +} + +/// Host-owned viewfinder for QR codes and barcodes. +/// +/// Optional, and listed on [`OptionalPlatform`] as [`ContactsPlatform`] is. +/// +/// The host draws the viewfinder and titles it with the requesting product's +/// id. Only `request.hint` is product text. It hands every code the camera +/// reads to the core's `ScanFilter` and acts on its verdict, and never follows +/// a scanned link itself. The core drops +/// the returned future when the product cancels, and the host closes the +/// viewfinder then. +#[async_trait] +pub trait ScannerPlatform: Send + Sync { + /// Open the viewfinder on behalf of `product` and wait for the user. + async fn scan_code( + &self, + product: &ProductContext, + request: &HostScannerScanRequest, + ) -> Result; +} + /// Combined platform interface. A host must provide every capability trait /// listed here. Members marked optional may be omitted; the core answers their /// product calls with `Unsupported`. See [`OptionalPlatform`]. @@ -3534,11 +3573,21 @@ impl Platform for T where /// with `Unsupported`. Codegen reads this list to emit each capability as an /// optional group on the host-callback surface. pub trait OptionalPlatform: - ChatPlatform + ContactsPlatform + PermissionStatusHost + PocketPlatform + GamePlatform + ChatPlatform + + ContactsPlatform + + PermissionStatusHost + + PocketPlatform + + GamePlatform + + ScannerPlatform { } impl OptionalPlatform for T where - T: ChatPlatform + ContactsPlatform + PermissionStatusHost + PocketPlatform + GamePlatform + T: ChatPlatform + + ContactsPlatform + + PermissionStatusHost + + PocketPlatform + + GamePlatform + + ScannerPlatform { } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index c5e3983ab8..338219d543 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -273,7 +273,7 @@ where /// withdrawn call stops waiting, so an answer given after the withdrawal /// authorizes nothing. The error is the one `remote_authority_call` answers, /// so each caller maps it into its method's own domain error. -async fn until_cancelled( +pub(crate) async fn until_cancelled( cx: &CallContext, wait: impl Future, ) -> Result { diff --git a/rust/crates/truapi/src/runtime/capabilities/scanner.rs b/rust/crates/truapi/src/runtime/capabilities/scanner.rs index 139e07b3b7..991bac8dc8 100644 --- a/rust/crates/truapi/src/runtime/capabilities/scanner.rs +++ b/rust/crates/truapi/src/runtime/capabilities/scanner.rs @@ -2,21 +2,67 @@ use tracing::instrument; use truapi::api::Scanner; +use truapi::latest; use truapi::versioned::scanner::{ HostScannerScanError, HostScannerScanRequest, HostScannerScanResponse, }; use truapi::{CallContext, CallError}; -use crate::runtime::ProductRuntimeHost; +use crate::host_logic::scanner::{accepts, validate_request}; +use crate::platform::HostScan; +use crate::runtime::{ProductRuntimeHost, until_cancelled}; + +fn domain(error: latest::HostScannerScanError) -> CallError { + CallError::Domain(HostScannerScanError::V1(error)) +} #[truapi::async_trait] impl Scanner for ProductRuntimeHost { + /// Checked in this order so a host without a scanner, or a request the + /// product could not make, never opens a viewfinder. #[instrument(skip_all, fields(runtime.method = "scanner.scan"))] async fn scan( &self, - _cx: &CallContext, - _request: HostScannerScanRequest, + cx: &CallContext, + request: HostScannerScanRequest, ) -> Result> { - Err(CallError::Unsupported) + let HostScannerScanRequest::V1(request) = request; + let platform = self + .services + .scanner_platform() + .ok_or(CallError::Unsupported)?; + validate_request(&request) + .map_err(|reason| domain(latest::HostScannerScanError::InvalidRequest { reason }))?; + let _claim = self + .services + .claim_scan() + .ok_or_else(|| domain(latest::HostScannerScanError::Busy))?; + + let answer = until_cancelled(cx, platform.scan_code(&self.product, &request)) + .await + .map_err(|_cancelled| CallError::Cancelled)? + .map_err(|error| { + domain(latest::HostScannerScanError::Unknown { + reason: error.reason, + }) + })?; + + let outcome = match answer { + HostScan::Scanned { text, format } if accepts(&request, format, &text) => { + latest::ScanOutcome::Scanned { text, format } + } + HostScan::Scanned { .. } => { + return Err(domain(latest::HostScannerScanError::Unknown { + reason: "the host returned a code the request does not accept".into(), + })); + } + HostScan::Dismissed => latest::ScanOutcome::Dismissed, + HostScan::CameraUnavailable => { + return Err(domain(latest::HostScannerScanError::CameraUnavailable)); + } + }; + Ok(HostScannerScanResponse::V1( + latest::HostScannerScanResponse { outcome }, + )) } } diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 1ea7ae2688..6534928115 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -5,7 +5,7 @@ //! controls live on the concrete role objects. use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; @@ -53,6 +53,12 @@ pub struct RuntimeServices { /// Host Game adapter, installed once at startup by a host that can hold /// reminders. Unset leaves every product Game call `Unsupported`. game_platform: OnceLock>, + /// Host scanner, installed once at startup by a host that can draw a + /// viewfinder. Unset leaves every product scan `Unsupported`. + scanner_platform: OnceLock>, + /// Whether a scan is open. Shared by every product runtime of this host, + /// because the device has one viewfinder. + scan_open: AtomicBool, /// Host observer told when a device finishes pairing with this signing /// host. Unset leaves a paired device unannounced. device_pairing_observer: OnceLock>, @@ -123,6 +129,8 @@ impl RuntimeServices { contacts_platform: OnceLock::new(), contact_handles: Default::default(), game_platform: OnceLock::new(), + scanner_platform: OnceLock::new(), + scan_open: AtomicBool::new(false), device_pairing_observer: OnceLock::new(), #[cfg(not(target_arch = "wasm32"))] core_db: OnceLock::new(), @@ -243,6 +251,29 @@ impl RuntimeServices { self.contacts_platform.get().cloned() } + /// Install the host's scanner. Answers whether this call was the one that + /// installed it. + pub fn install_scanner_platform( + &self, + platform: Arc, + ) -> bool { + self.scanner_platform.set(platform).is_ok() + } + + /// The host's scanner, when one is installed. + pub fn scanner_platform(&self) -> Option> { + self.scanner_platform.get().cloned() + } + + /// Claim the one open scan, or `None` while another holds it. Dropping the + /// claim frees it, so a cancelled or failed scan cannot keep it. + pub(crate) fn claim_scan(&self) -> Option> { + self.scan_open + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| ScanClaim(&self.scan_open)) + } + /// Install the host's device-pairing observer. /// /// Set-once, like every optional capability, so the surface that announces @@ -450,3 +481,12 @@ impl RuntimeChainProvider for HostChainProvider { }) } } + +/// The open scan. Frees the slot when dropped. +pub(crate) struct ScanClaim<'a>(&'a AtomicBool); + +impl Drop for ScanClaim<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index eb32e53986..3f7095c68d 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -50,7 +50,9 @@ use truapi::versioned::resource_allocation::{ HostRequestResourceAllocationError, HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, }; -use truapi::versioned::scanner::HostScannerScanRequest; +use truapi::versioned::scanner::{ + HostScannerScanError, HostScannerScanRequest, HostScannerScanResponse, +}; use truapi::versioned::signing::{ HostCreateTransactionError, HostCreateTransactionRequest, HostCreateTransactionResponse, HostCreateTransactionWithLegacyAccountError, HostCreateTransactionWithLegacyAccountRequest, @@ -7684,21 +7686,251 @@ fn an_internal_cancellation_never_becomes_the_cancelled_variant() { } } -/// A product tells "no scanner here" by `Unsupported` and falls back to its own -/// camera code; a `HostFailure` would read as a real failure. -#[test] -fn scanner_scan_is_unsupported_until_a_host_implements_it() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); +/// A scanner whose answer is fixed, recording each product and request it is +/// asked for. A parked one never answers, standing in for a user who has not +/// scanned yet, and records when the core stops waiting on it. +struct StubScannerPlatform { + answer: crate::platform::HostScan, + park: bool, + asked: Mutex>, + abandoned: Arc, +} + +impl StubScannerPlatform { + fn answering(answer: crate::platform::HostScan) -> Arc { + Arc::new(Self { + answer, + park: false, + asked: Mutex::new(Vec::new()), + abandoned: Arc::default(), + }) + } + + fn parked() -> Arc { + Arc::new(Self { + answer: crate::platform::HostScan::Dismissed, + park: true, + asked: Mutex::new(Vec::new()), + abandoned: Arc::default(), + }) + } + + fn asked(&self) -> Vec<(String, truapi::latest::HostScannerScanRequest)> { + self.asked.lock().unwrap().clone() + } +} + +/// Sets its flag when dropped, which is how a parked scan sees the core stop +/// waiting. +struct SetOnDrop(Arc); + +impl Drop for SetOnDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +#[truapi::async_trait] +impl crate::platform::ScannerPlatform for StubScannerPlatform { + async fn scan_code( + &self, + product: &crate::platform::ProductContext, + request: &truapi::latest::HostScannerScanRequest, + ) -> Result { + self.asked + .lock() + .unwrap() + .push((product.product_id.clone(), request.clone())); + if self.park { + let _abandoned = SetOnDrop(self.abandoned.clone()); + futures::future::pending::<()>().await; + } + Ok(self.answer.clone()) + } +} + +/// A runtime for `greenmarket.dot` with no session, and `scanner` installed +/// when given. +fn scanner_host(scanner: Option>) -> ProductRuntimeHost { + let (host_config, product) = runtime_config("greenmarket.dot"); + let services = RuntimeServices::with_chat_platform( + stub_platform() as Arc, + host_config.host.host_info.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + host_config.asset_hub_chain_genesis_hash, + test_spawner(), + None, + ); + if let Some(scanner) = scanner { + services.install_scanner_platform(scanner); + } + let pairing_host = PairingHost::new(services.clone(), host_config); + let adapters = crate::host_core::ConnectionAdapters::from_services(&services); + ProductRuntimeHost::from_services(services, adapters, pairing_host, product) +} +fn receipt_request() -> truapi::latest::HostScannerScanRequest { + truapi::latest::HostScannerScanRequest { + formats: vec![v01::CodeFormat::Qr], + prefix: Some("https://greenmarket.example/r/".into()), + hint: Some("Point at the receipt's QR code".into()), + } +} + +fn scan_in( + host: &ProductRuntimeHost, + cx: &CallContext, +) -> Result> { let result = futures::executor::block_on(Scanner::scan( + host, + cx, + HostScannerScanRequest::V1(receipt_request()), + )); + unwrap_scan(result) +} + +fn unwrap_scan( + result: Result>, +) -> Result> { + match result { + Ok(HostScannerScanResponse::V1(response)) => Ok(response.outcome), + Err(CallError::Domain(HostScannerScanError::V1(error))) => Err(CallError::Domain(error)), + Err(CallError::Unsupported) => Err(CallError::Unsupported), + Err(CallError::Cancelled) => Err(CallError::Cancelled), + Err(other) => panic!("unexpected scanner error: {other:?}"), + } +} + +fn scan(host: &ProductRuntimeHost) -> Result> { + scan_in(host, &CallContext::default()) +} + +fn scanned(text: &str, format: v01::CodeFormat) -> crate::platform::HostScan { + crate::platform::HostScan::Scanned { + text: text.into(), + format, + } +} + +#[test] +fn scanning_without_a_scanner_is_unsupported() { + // A product reads `Unsupported` as "use your own camera code"; a + // `HostFailure` would read as a real failure. + let host = scanner_host(None); + assert_eq!(scan(&host), Err(CallError::Unsupported)); +} + +#[test] +fn a_scan_returns_the_code_and_names_the_product_without_a_session() { + // No session is installed: scanning reads no account, so it works signed out. + let scanner = StubScannerPlatform::answering(scanned( + "https://greenmarket.example/r/BAG6", + v01::CodeFormat::Qr, + )); + let host = scanner_host(Some(scanner.clone())); + + assert_eq!( + scan(&host), + Ok(v01::ScanOutcome::Scanned { + text: "https://greenmarket.example/r/BAG6".into(), + format: v01::CodeFormat::Qr, + }) + ); + // The host titles the viewfinder with this id, so it must be the caller's. + assert_eq!( + scanner.asked(), + vec![("greenmarket.dot".to_owned(), receipt_request())] + ); +} + +#[test] +fn a_dismissal_is_an_outcome_and_a_missing_camera_is_an_error() { + // The product offers a dismissed scan again, but not one with no camera. + let dismissed = scanner_host(Some(StubScannerPlatform::answering( + crate::platform::HostScan::Dismissed, + ))); + assert_eq!(scan(&dismissed), Ok(v01::ScanOutcome::Dismissed)); + + let no_camera = scanner_host(Some(StubScannerPlatform::answering( + crate::platform::HostScan::CameraUnavailable, + ))); + assert_eq!( + scan(&no_camera), + Err(CallError::Domain(v01::HostScannerScanError::CameraUnavailable)) + ); +} + +#[test] +fn an_invalid_request_never_reaches_the_host() { + // No viewfinder may open for a request the product could not make. + let scanner = StubScannerPlatform::answering(crate::platform::HostScan::Dismissed); + let host = scanner_host(Some(scanner.clone())); + let mut request = receipt_request(); + request.hint = Some("Scan the code on your computer\nto sign in".into()); + + let result = unwrap_scan(futures::executor::block_on(Scanner::scan( &host, &CallContext::default(), - HostScannerScanRequest::V1(v01::HostScannerScanRequest { - formats: vec![v01::CodeFormat::Qr], - prefix: None, - hint: None, - }), + HostScannerScanRequest::V1(request), + ))); + + assert!(matches!( + result, + Err(CallError::Domain(v01::HostScannerScanError::InvalidRequest { .. })) + )); + assert!(scanner.asked().is_empty()); +} + +#[test] +fn a_host_answer_the_request_does_not_accept_never_reaches_the_product() { + // A buggy host must not hand a receipts product a pairing link, or the + // right text in a format it did not ask for. + for answer in [ + scanned("polkadotapp://pair?handshake=00", v01::CodeFormat::Qr), + scanned("https://greenmarket.example/r/BAG6", v01::CodeFormat::Code128), + ] { + let host = scanner_host(Some(StubScannerPlatform::answering(answer))); + assert!(matches!( + scan(&host), + Err(CallError::Domain(v01::HostScannerScanError::Unknown { .. })) + )); + } +} + +#[test] +fn a_second_scan_waits_its_turn_and_a_cancel_frees_the_viewfinder() { + // The device has one viewfinder. Cancelling the open scan must close it, + // which the host sees as its wait being dropped, and let the next one in. + let scanner = StubScannerPlatform::parked(); + let host = scanner_host(Some(scanner.clone())); + let cancel = truapi::CancellationToken::default(); + let first_cx = CallContext::with_parts("scan-first".into(), cancel.clone()); + let mut first = Box::pin(Scanner::scan( + &host, + &first_cx, + HostScannerScanRequest::V1(receipt_request()), )); + assert!(futures::FutureExt::now_or_never(&mut first).is_none()); + + assert_eq!( + scan(&host), + Err(CallError::Domain(v01::HostScannerScanError::Busy)) + ); - assert!(matches!(result, Err(CallError::Unsupported))); + cancel.cancel(); + assert_eq!( + unwrap_scan(futures::executor::block_on(first)), + Err(CallError::Cancelled) + ); + assert!(scanner.abandoned.load(Ordering::SeqCst)); + + let next_cx = CallContext::default(); + let mut next = Box::pin(Scanner::scan( + &host, + &next_cx, + HostScannerScanRequest::V1(receipt_request()), + )); + assert!(futures::FutureExt::now_or_never(&mut next).is_none()); + assert_eq!(scanner.asked().len(), 2); } From e6d11cfa1903d5b84631c0d10cb21b795f0df913 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:33:34 -0300 Subject: [PATCH 05/29] fix(truapi): stop waiting on the contact picker when the product cancels contacts.pick ignored the call's cancellation, so a withdrawn pick kept the picker open until the user acted. The core now drops its wait, which is the host's signal to close the picker. --- rust/crates/truapi/src/runtime.rs | 6 +-- rust/crates/truapi/src/runtime/tests.rs | 49 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 338219d543..d7077a2ce1 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -1379,7 +1379,7 @@ impl Contacts for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "contacts.pick"))] async fn pick( &self, - _cx: &CallContext, + cx: &CallContext, _request: HostContactsPickRequest, ) -> Result> { let wrap = HostContactsPickError::V1; @@ -1396,9 +1396,9 @@ impl Contacts for ProductRuntimeHost { // Read before the picker opens: a removal signalled while the user is // choosing must not be undone by caching their choice. let generation = self.services.contact_handles.generation(); - let outcome = match platform - .pick_contact(&self.product) + let outcome = match until_cancelled(cx, platform.pick_contact(&self.product)) .await + .map_err(|_cancelled| CallError::Cancelled)? .map_err(unknown)? { crate::platform::HostContactPick::Picked { account } => { diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 3f7095c68d..eff8655079 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7934,3 +7934,52 @@ fn a_second_scan_waits_its_turn_and_a_cancel_frees_the_viewfinder() { assert!(futures::FutureExt::now_or_never(&mut next).is_none()); assert_eq!(scanner.asked().len(), 2); } + +/// A contact picker the user never answers, which records when the core stops +/// waiting on it. +struct ParkedContactsPlatform { + abandoned: Arc, +} + +#[truapi::async_trait] +impl crate::platform::ContactsPlatform for ParkedContactsPlatform { + async fn contacts( + &self, + lookup: &crate::platform::HostContactLookup, + ) -> Result { + Ok(crate::platform::HostContactMatches { + accounts: vec![None; lookup.handles.len()], + }) + } + + async fn pick_contact( + &self, + _product: &ProductContext, + ) -> Result { + let _abandoned = SetOnDrop(self.abandoned.clone()); + futures::future::pending().await + } +} + +#[test] +fn cancelling_a_contact_pick_stops_waiting_on_the_picker() { + // The host closes its picker when the core drops the wait. Without that, a + // withdrawn pick leaves the picker on screen until the user acts. + let abandoned = Arc::new(AtomicBool::new(false)); + let host = host_with_contacts(Arc::new(ParkedContactsPlatform { + abandoned: abandoned.clone(), + })); + let cancel = truapi::CancellationToken::default(); + let cx = CallContext::with_parts("pick-withdrawn".into(), cancel.clone()); + let mut pick = Box::pin(Contacts::pick( + &host, + &cx, + HostContactsPickRequest::V1(v01::HostContactsPickRequest {}), + )); + assert!(futures::FutureExt::now_or_never(&mut pick).is_none()); + + cancel.cancel(); + + assert_eq!(futures::executor::block_on(pick), Err(CallError::Cancelled)); + assert!(abandoned.load(Ordering::SeqCst)); +} From 93cd4657ee0e77d7cbc7513de01065b643c15c2d Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:37:45 -0300 Subject: [PATCH 06/29] fix(native): route contact pick errors through the shared helper The Kotlin contacts adapter caught CancellationException and answered it as a host rejection, so a withdrawn pick never unwound. It now uses withHostRejection, which lets cancellation through. On Swift the four private copies of withHostRejection become one file-level pair, which the contacts adapter now uses too. --- .../kotlin/io/parity/truapi/TrUAPIHost.kt | 8 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 86 +++++-------------- 2 files changed, 23 insertions(+), 71 deletions(-) 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 312838ffe0..f8b46fb149 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 @@ -668,13 +668,7 @@ private class ContactsCallbackAdapter(private val bridge: ContactsHostBridge) : withHostRejection { bridge.contacts(lookup) } override suspend fun pickContact(productId: String): HostContactPick = - try { - bridge.pickContact(productId) - } catch (error: HostRejection) { - throw error - } catch (error: Throwable) { - throw HostRejection.Rejected(hostRejectionReason(error)) - } + withHostRejection { bridge.pickContact(productId) } } private class PocketCallbackAdapter(private val bridge: PocketHostBridge) : NativePocketCallbacks { diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index d76b12f331..500519ab30 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -399,16 +399,6 @@ private final class ChatCallbackAdapter: NativeChatCallbacks, @unchecked Sendabl func listRooms() async throws -> [ChatRoom] { try await withHostRejection { try await bridge.listRooms() } } - - private func withHostRejection(_ operation: () async throws -> T) async throws -> T { - do { - return try await operation() - } catch let error as HostRejection { - throw error - } catch { - throw HostRejection.Rejected(reason: hostRejectionReason(error)) - } - } } /// Adapter that bridges the public `PocketHostBridge` to the generated UniFFI @@ -427,16 +417,6 @@ private final class PocketCallbackAdapter: NativePocketCallbacks, @unchecked Sen func removeCard(cardId: String) throws -> NativePocketRemoval { try withHostRejection { try bridge.removeCard(cardId: cardId) } } - - private func withHostRejection(_ operation: () throws -> T) throws -> T { - do { - return try operation() - } catch let error as HostRejection { - throw error - } catch { - throw HostRejection.Rejected(reason: hostRejectionReason(error)) - } - } } /// Adapter that bridges the public `GameHostBridge` to the generated UniFFI @@ -455,16 +435,6 @@ private final class GameCallbackAdapter: NativeGameCallbacks, @unchecked Sendabl func cancelReminder() async throws { try await withHostRejection { try await bridge.cancelReminder() } } - - private func withHostRejection(_ operation: () async throws -> T) async throws -> T { - do { - return try await operation() - } catch let error as HostRejection { - throw error - } catch { - throw HostRejection.Rejected(reason: hostRejectionReason(error)) - } - } } /// Adapter that bridges the public `ContactsHostBridge` to the generated @@ -477,23 +447,11 @@ private final class ContactsCallbackAdapter: NativeContactsCallbacks, @unchecked } func contacts(lookup: HostContactLookup) throws -> HostContactMatches { - do { - return try bridge.contacts(lookup: lookup) - } catch let error as HostRejection { - throw error - } catch { - throw HostRejection.Rejected(reason: hostRejectionReason(error)) - } + try withHostRejection { try bridge.contacts(lookup: lookup) } } func pickContact(productId: String) async throws -> HostContactPick { - do { - return try await bridge.pickContact(productId: productId) - } catch let error as HostRejection { - throw error - } catch { - throw HostRejection.Rejected(reason: hostRejectionReason(error)) - } + try await withHostRejection { try await bridge.pickContact(productId: productId) } } } @@ -681,26 +639,6 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } - private func withHostRejection(_ operation: () throws -> T) throws -> T { - do { - return try operation() - } catch let error as HostRejection { - throw error - } catch { - throw HostRejection.Rejected(reason: hostRejectionReason(error)) - } - } - - private func withHostRejection(_ operation: () async throws -> T) async throws -> T { - do { - return try await operation() - } catch let error as HostRejection { - throw error - } catch { - throw HostRejection.Rejected(reason: hostRejectionReason(error)) - } - } - private func withNavigationRejection(_ operation: () throws -> T) throws -> T { do { return try operation() @@ -1169,6 +1107,26 @@ public final class TrUAPIProductExecution: TrUAPIProductExecutionProtocol, @unch } } +private func withHostRejection(_ operation: () throws -> T) throws -> T { + do { + return try operation() + } catch let error as HostRejection { + throw error + } catch { + throw HostRejection.Rejected(reason: hostRejectionReason(error)) + } +} + +private func withHostRejection(_ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch let error as HostRejection { + throw error + } catch { + throw HostRejection.Rejected(reason: hostRejectionReason(error)) + } +} + /// Reason text for an error a host threw from a callback. /// /// A value that is not a `LocalizedError` has no author-written description, From b289f48d1c19abc7809f470ac4f6d9959cc468d7 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:37:46 -0300 Subject: [PATCH 07/29] feat(native): expose the scanner to Swift and Kotlin hosts NativeScannerCallbacks, the ScannerHostBridge wrappers with setScanner, and ScanFilter and ScanVerdict, which a host's viewfinder calls for every code it reads. --- .../kotlin/io/parity/truapi/TrUAPIHost.kt | 37 +++++++++++++++++ .../Sources/TrUAPIHost/TrUAPIHost.swift | 41 +++++++++++++++++++ rust/crates/truapi/src/native.rs | 3 +- rust/crates/truapi/src/native/callbacks.rs | 20 +++++++++ rust/crates/truapi/src/native/platform.rs | 25 ++++++++++- rust/crates/truapi/src/native/runtime.rs | 15 ++++++- 6 files changed, 137 insertions(+), 4 deletions(-) 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 f8b46fb149..5afa2a6056 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 @@ -89,6 +89,9 @@ import uniffi.truapi.HostContactLookup import uniffi.truapi.HostContactMatches import uniffi.truapi.HostContactPick import uniffi.truapi.NativeContactsCallbacks +import uniffi.truapi.HostScan +import uniffi.truapi.HostScannerScanRequest +import uniffi.truapi.NativeScannerCallbacks /** Package metadata. */ object TrUAPIHost { @@ -671,6 +674,24 @@ private class ContactsCallbackAdapter(private val bridge: ContactsHostBridge) : withHostRejection { bridge.pickContact(productId) } } +/** + * Draws the host's viewfinder when a product scans a code. + * + * Title the viewfinder with [productId] and show `request.hint` under it as the + * product's words. Hand every code the camera reads to a `ScanFilter` built from + * [request] and act on its verdict. Never follow a scanned link. Close the + * viewfinder when the coroutine is cancelled. + */ +interface ScannerHostBridge { + @Throws(HostRejection::class) + suspend fun scanCode(productId: String, request: HostScannerScanRequest): HostScan +} + +private class ScannerCallbackAdapter(private val bridge: ScannerHostBridge) : NativeScannerCallbacks { + override suspend fun scanCode(productId: String, request: HostScannerScanRequest): HostScan = + withHostRejection { bridge.scanCode(productId, request) } +} + private class PocketCallbackAdapter(private val bridge: PocketHostBridge) : NativePocketCallbacks { override fun listCards(): List = withHostRejection { bridge.listCards() } @@ -732,6 +753,22 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor return inner.setContactsCallbacks(adapter) } + // Co-owns the scanner adapter for as long as the runtime holds it. + private var scannerRetainer: NativeScannerCallbacks? = null + + /** + * Install the host's scanner, which draws the viewfinder. + * + * Set-once, so the viewfinder cannot change hands under a running product. + * Returns whether this call installed it. Call it before opening any + * product execution. + */ + fun setScanner(scanner: ScannerHostBridge): Boolean { + val adapter = ScannerCallbackAdapter(scanner) + scannerRetainer = adapter + return inner.setScannerCallbacks(adapter) + } + /** * Tell the core the host's contacts changed. Call it whenever a contact is * removed or blocked, so a contact handle the core cached stops resolving. diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 500519ab30..8733129b72 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -303,6 +303,18 @@ public protocol ContactsHostBridge: AnyObject, Sendable { func pickContact(productId: String) async throws -> HostContactPick } +/// Draws the host's viewfinder when a product scans a code. Installed once on +/// ``TrUAPIHostRuntime/setScanner(_:)``. A runtime without one answers +/// `scanner.scan` with `Unsupported`. +/// +/// Title the viewfinder with `productId` and show `request.hint` under it as +/// the product's words. Hand every code the camera reads to a `ScanFilter` +/// built from `request` and act on its verdict. Never follow a scanned link. +/// Close the viewfinder when the task is cancelled. +public protocol ScannerHostBridge: AnyObject, Sendable { + func scanCode(productId: String, request: HostScannerScanRequest) async throws -> HostScan +} + public extension HostBridge { /// Default no-op logger. Override to plumb into your logging framework. func onCoreLog(marker: String, detail: String) {} @@ -455,6 +467,22 @@ private final class ContactsCallbackAdapter: NativeContactsCallbacks, @unchecked } } +/// Adapter that bridges the public `ScannerHostBridge` to the generated UniFFI +/// `NativeScannerCallbacks` protocol. +private final class ScannerCallbackAdapter: NativeScannerCallbacks, @unchecked Sendable { + private let bridge: ScannerHostBridge + + init(bridge: ScannerHostBridge) { + self.bridge = bridge + } + + func scanCode(productId: String, request: HostScannerScanRequest) async throws -> HostScan { + try await withHostRejection { + try await bridge.scanCode(productId: productId, request: request) + } + } +} + /// Adapter that bridges the public `HostBridge` to the generated UniFFI /// `HostCallbacks` protocol. Kept private so the generated names never /// leak into consumers. @@ -678,6 +706,7 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { private let notificationCenter: NotificationCenter private let foregroundObserver: NSObjectProtocol private var contactsRetainer: NativeContactsCallbacks? + private var scannerRetainer: NativeScannerCallbacks? public convenience init(bridge: HostBridge, runtimeConfig: HostRuntimeConfig) throws { try self.init(bridge: bridge, runtimeConfig: runtimeConfig, notificationCenter: .default) @@ -726,6 +755,18 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { return inner.setContactsCallbacks(callbacks: adapter) } + /// Install the host's scanner, which draws the viewfinder. + /// + /// Set-once, so the viewfinder cannot change hands under a running product. + /// Answers whether this call installed it. Call it before opening any + /// product execution. + @discardableResult + public func setScanner(_ scanner: ScannerHostBridge) -> Bool { + let adapter = ScannerCallbackAdapter(bridge: scanner) + scannerRetainer = adapter + return inner.setScannerCallbacks(callbacks: adapter) + } + /// Tell the core the host's contacts changed. Call it whenever a contact /// is removed or blocked, so a contact handle the core cached stops /// resolving. diff --git a/rust/crates/truapi/src/native.rs b/rust/crates/truapi/src/native.rs index 7268513ba1..c8bcf06a95 100644 --- a/rust/crates/truapi/src/native.rs +++ b/rust/crates/truapi/src/native.rs @@ -30,8 +30,9 @@ pub use crate::host_internal::sso_messages::SsoRequestOutcome; pub use crate::host_logic::dotns::{NavigateDecision, PocketDeeplinkAction}; pub use callbacks::{ HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeGameCallbacks, - NativePocketCallbacks, NativePocketRemoval, + NativePocketCallbacks, NativePocketRemoval, NativeScannerCallbacks, }; +pub use crate::host_logic::scanner::{ScanFilter, ScanVerdict}; pub use config::{HostRuntimeConfig, NativeRuntimeConfigError, ProductExecutionConfig}; pub use errors::{HostRejection, NativeChatFieldError, NativeCoreDatabaseError, NativeRendererError}; pub use renderer::{NativeRendererObserver, NativeRendererSubscription}; diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 31c1b0ce4e..f323a2a6f4 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -325,3 +325,23 @@ pub trait NativeContactsCallbacks: Send + Sync { product_id: String, ) -> Result; } + +/// Native scanner adapter. A host that can draw a viewfinder passes an +/// implementation to [`NativeTrUApiHostRuntime::set_scanner_callbacks`]; one +/// without leaves `scanner.scan` answering `Unsupported`. +/// +/// Title the viewfinder with `product_id` and show `request.hint` under it as +/// the product's words. Hand every code the camera reads to a +/// [`crate::host_logic::scanner::ScanFilter`] built from `request`, and never +/// follow a scanned link. Dropping the returned future cancels the foreign +/// task; close the viewfinder when that happens. +#[uniffi::export(rust, foreign)] +#[async_trait::async_trait] +pub trait NativeScannerCallbacks: Send + Sync { + /// Open the viewfinder on behalf of `product_id` and report how it ended. + async fn scan_code( + &self, + product_id: String, + request: truapi::latest::HostScannerScanRequest, + ) -> Result; +} diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 0c50c1a5f8..79018f419a 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -17,7 +17,7 @@ use crate::{DevicePairingObserver, PairedSsoPeer}; use super::callbacks::{ HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeGameCallbacks, - NativePocketCallbacks, NativePocketRemoval, + NativePocketCallbacks, NativePocketRemoval, NativeScannerCallbacks, }; use super::errors::HostRejection; use super::events::NativeEventBus; @@ -55,6 +55,29 @@ impl crate::platform::ContactsPlatform for ContactsCallbackPlatform { } } +/// [`crate::platform::ScannerPlatform`] served by host-provided +/// [`NativeScannerCallbacks`]; constructed only when the host passed one. +pub struct ScannerCallbackPlatform { + /// Host viewfinder. + pub scanner: Arc, +} + +#[async_trait] +impl crate::platform::ScannerPlatform for ScannerCallbackPlatform { + async fn scan_code( + &self, + product: &ProductContext, + request: &truapi::latest::HostScannerScanRequest, + ) -> Result { + self.scanner + .scan_code(product.product_id.clone(), request.clone()) + .await + .map_err(|error| v01::GenericError { + reason: error.to_string(), + }) + } +} + /// Every [`crate::platform::Platform`] trait served by one execution's /// [`HostCallbacks`]. pub struct CallbackPlatform { diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index c85a935ce3..d99009b0d7 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -26,7 +26,7 @@ use crate::{PairedSsoPeer, ResponderExit, SigningHostRuntime}; use super::callbacks::{ HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeGameCallbacks, - NativePocketCallbacks, + NativePocketCallbacks, NativeScannerCallbacks, }; use super::config::{ HostRuntimeConfig, NativeResolvedHostRuntimeConfig, NativeRuntimeConfigError, @@ -37,7 +37,7 @@ use super::executor::shared_native_executor; use super::events::NativeEventBus; use super::platform::{ CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, GameCallbackPlatform, - PocketCallbackPlatform, + PocketCallbackPlatform, ScannerCallbackPlatform, }; #[cfg(doc)] use crate::WorkerTransition; @@ -276,6 +276,17 @@ impl NativeTrUApiHostRuntime { })) } + /// Install the host's scanner, which draws the viewfinder. + /// + /// Set-once, so the viewfinder cannot change hands under a running + /// product. Answers whether this call installed it. Call it before opening + /// any product execution; a runtime without one answers `scanner.scan` + /// with `Unsupported`. + pub fn set_scanner_callbacks(&self, callbacks: Arc) -> bool { + self.runtime + .set_scanner_platform(Arc::new(ScannerCallbackPlatform { scanner: callbacks })) + } + /// Tell the core the host's contacts changed. Call it whenever a contact /// is removed or blocked, so a handle the core cached stops resolving. pub fn notify_contacts_changed(&self) { From e13c4d3248f4fca143ba32589d47860a1fd86627 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:46:43 -0300 Subject: [PATCH 08/29] feat(truapi-host): serve the scanner in the browser bridge and the test host Wires the scanner capability through the WASM runtimes and the worker handshake. The mock host serves a scanner when created with a scanner answer, and setScanAnswer changes the next one. The Rust MockPlatform gets the same control so the two mocks stay in step. --- js/packages/truapi-host/README.md | 10 ++++ js/packages/truapi-host/src/test-support.ts | 10 ++++ .../src/testing/create-mock-client.test.ts | 43 +++++++++++++++++ .../truapi-host/src/testing/playwright.ts | 8 ++++ .../src/testing/test-host-surface.test.ts | 1 + .../truapi-host/src/web/create-mock-host.ts | 32 +++++++++++++ .../src/web/create-worker-host-runtime.ts | 1 + .../src/web/worker-provider.test.ts | 4 ++ rust/crates/truapi/src/platform/mock.rs | 46 +++++++++++++++++-- rust/crates/truapi/src/wasm.rs | 17 +++++++ 10 files changed, 169 insertions(+), 3 deletions(-) diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index e513243959..0c86d66a54 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -123,6 +123,7 @@ const callbacks: HostCallbacks = { pocket, // optional: serves the host's Pocket card collection game, // optional: holds the host's game reminders contacts, // optional: leave it out and contacts calls get `Unsupported` + scanner, // optional: draws the host's QR and barcode viewfinder }; ``` @@ -279,6 +280,15 @@ The core re-checks every account returned. It caches what it resolves, so call `notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). +`scanner.scanCode(product, request)` opens the host's viewfinder and answers +`Scanned { text, format }`, `Dismissed`, or `CameraUnavailable`. Title it with +the product id, show `request.hint` under the title as the product's words, and +never follow a scanned link. The core checks the request before calling and +checks the answer after, so a code the request does not accept never reaches +the product. The mock test host serves no scanner by default. Create it with +`createMockHost({ scanner: answer })` to serve one, and change the next answer +with `setScanAnswer(answer)`. + ## Generated WASM artefacts The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index 0d4f35dd65..17e3630f9f 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -140,6 +140,16 @@ export function makeHostCallbacks( }, } : {}), + // And for the scanner: the default fixture is a host with no viewfinder, + // so scans are answered `Unsupported`, as on a web host. + ...(overrides.scanner + ? { + scanner: { + scanCode: async () => ({ tag: "Dismissed" as const }), + ...overrides.scanner, + }, + } + : {}), }; } diff --git a/js/packages/truapi-host/src/testing/create-mock-client.test.ts b/js/packages/truapi-host/src/testing/create-mock-client.test.ts index f0d79974e0..c68fc25779 100644 --- a/js/packages/truapi-host/src/testing/create-mock-client.test.ts +++ b/js/packages/truapi-host/src/testing/create-mock-client.test.ts @@ -56,6 +56,49 @@ suite("createMockClient", () => { } }); + it("answers a product's scan with the code the test configured", async () => { + // The browser joins for an optional capability are written by hand, so a + // missing one leaves every product scan `Unsupported` while unit tests + // pass. Only a call through the real core proves the scanner is reachable. + const { client, host, dispose } = await createMockClient({ + mock: { + scanner: { + tag: "Scanned", + value: { text: "https://greenmarket.example/r/1", format: "Qr" }, + }, + }, + }); + try { + const first = await client.scanner.scan({ + formats: ["Qr"], + prefix: "https://greenmarket.example/r/", + }); + expect(first._unsafeUnwrap().outcome).toEqual({ + tag: "Scanned", + value: { text: "https://greenmarket.example/r/1", format: "Qr" }, + }); + + // A "Scan another" flow: the next scan gets the next answer. + host.setScanAnswer({ tag: "Dismissed" }); + const second = await client.scanner.scan({ formats: ["Qr"] }); + expect(second._unsafeUnwrap().outcome).toEqual({ tag: "Dismissed" }); + } finally { + dispose(); + } + }); + + it("leaves scanning unsupported when the test configures no scanner", async () => { + // A suite that never mentions scanning sees what a web host answers. + const { client, host, dispose } = await createMockClient(); + try { + const result = await client.scanner.scan({ formats: ["Qr"] }); + expect(result._unsafeUnwrapErr().tag).toBe("Unsupported"); + expect(() => host.setScanAnswer({ tag: "Dismissed" })).toThrow(); + } finally { + dispose(); + } + }); + it("resolves a seeded preimage, so the seeded key is the one the core asks for", async () => { // `seedPreimage` is only worth anything if the key it hands back is the key // the core asks the host for. The core content-addresses preimages and diff --git a/js/packages/truapi-host/src/testing/playwright.ts b/js/packages/truapi-host/src/testing/playwright.ts index 6ac3597f23..d3817ba54f 100644 --- a/js/packages/truapi-host/src/testing/playwright.ts +++ b/js/packages/truapi-host/src/testing/playwright.ts @@ -23,6 +23,7 @@ import { type SigningLogEntry, type StatementEntry, } from "../web/create-mock-host.js"; +import type { HostScan } from "../generated/host-callbacks.js"; import type { StatementInput } from "../web/loopback-statements.js"; // The statement shapes `@parity/host-api-test-sdk` names, so a suite that @@ -265,6 +266,12 @@ export interface TestHost { clearPreimages(): Promise; getTheme(): Promise; setTheme(variant: string): Promise; + /** + * Set what the next product scan answers. Throws unless the mock was created + * with a `scanner` answer, because a host serves the scanner or not from + * the start. + */ + setScanAnswer(answer: HostScan): Promise; getIsAuthenticated(): Promise; getChainStatus(): Promise; getConnectionStatus(): Promise; @@ -754,6 +761,7 @@ export function createTestHostFixture(defaults: TestHostFixtureOptions) { clearPreimages: () => call("clearPreimages"), getTheme: () => call("getTheme"), setTheme: (variant) => call("setTheme", variant), + setScanAnswer: (answer) => call("setScanAnswer", answer), getIsAuthenticated: () => call("getIsAuthenticated"), getChainStatus: () => call("getChainStatus"), getConnectionStatus: () => call("getConnectionStatus"), diff --git a/js/packages/truapi-host/src/testing/test-host-surface.test.ts b/js/packages/truapi-host/src/testing/test-host-surface.test.ts index af0e289931..b91de8525e 100644 --- a/js/packages/truapi-host/src/testing/test-host-surface.test.ts +++ b/js/packages/truapi-host/src/testing/test-host-surface.test.ts @@ -50,6 +50,7 @@ const TEST_HOST_API = [ "setPaymentBalance", "setPaymentTopUpBehavior", "setPermissionBehavior", + "setScanAnswer", "setTheme", "simulateDisconnect", "simulatePaymentStatus", diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index 06b15bcc61..c22724935a 100644 --- a/js/packages/truapi-host/src/web/create-mock-host.ts +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -42,6 +42,7 @@ import type { AuthState, CoreStorageKey, HostChainSet, + HostScan, JsonRpcConnection, PermissionDecision, RequiredHostCallbacks, @@ -399,6 +400,11 @@ export interface MockHostConfig { languageTag?: string; /** Whether `confirmUserAction` confirms reviewed actions. Default `true`. */ confirmUserActions?: boolean; + /** + * What the scanner answers a product's scan. Unset (the default) serves no + * scanner, so `scanner.scan` is `Unsupported`, as on a web host. + */ + scanner?: HostScan; /** * JSON-RPC response frames the chain connection replays, in order. Empty * (the default) means a silent connection: it records outbound requests and @@ -550,6 +556,12 @@ export interface MockHost { getTheme(): ThemeVariant; /** Replace the reported theme. */ setTheme(variant: ThemeVariant): void; + /** + * Set what the next product scan answers. Throws unless the mock was created + * with a `scanner` answer, because a host serves the scanner or not from + * the start. + */ + setScanAnswer(answer: HostScan): void; /** State of the mock's chain connection. */ getChainStatus(): ChainStatus; /** Mark the chain disconnected, as a dropped transport would. */ @@ -845,6 +857,7 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { chainProxies = [], languageTag = "en", faults = {}, + scanner, supportedChains = { network: "mock", chains: [ @@ -855,6 +868,7 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { }, } = config; + let scanAnswer: HostScan = scanner ?? { tag: "Dismissed" }; const storage = new Map(); const preimages = new Map(); const navigations: string[] = []; @@ -1166,6 +1180,16 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { async cancelGameReminder() {}, }, + ...(scanner === undefined + ? {} + : { + scanner: { + async scanCode() { + return scanAnswer; + }, + }, + }), + permissions: { async devicePermission(_product, request) { if (faults.permissionError) throw new Error(faults.permissionError); @@ -1494,6 +1518,14 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { enforcePermissions = enforce; }, getTheme: () => currentTheme, + setScanAnswer: (answer) => { + if (scanner === undefined) { + throw new Error( + "this mock serves no scanner: create it with a `scanner` answer", + ); + } + scanAnswer = answer; + }, setTheme: (variant) => { currentTheme = variant; const item: HostThemeSubscribeItem = { 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 eeb1d36901..d1bfb27b46 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 @@ -1254,6 +1254,7 @@ export function createWebWorkerPairingHostRuntime( pocket: host.pocket !== undefined, game: host.game !== undefined, contacts: host.contacts !== undefined, + scanner: host.scanner !== undefined, }, debuggerUrl: debuggerDial, role: options.role, 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 f20d03fb7f..8277c5280e 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -149,6 +149,7 @@ describe("createWebWorkerPairingHostRuntime", () => { pocket: false, game: false, contacts: false, + scanner: false, }, // Null under `bun test`: the `import.meta.env.DEV` gate reads undefined, // so no dial resolves and the worker builds no tap. @@ -188,6 +189,7 @@ describe("createWebWorkerPairingHostRuntime", () => { pocket: false, game: false, contacts: false, + scanner: false, }); }); @@ -211,6 +213,7 @@ describe("createWebWorkerPairingHostRuntime", () => { pocket: true, game: false, contacts: false, + scanner: false, }); }); @@ -237,6 +240,7 @@ describe("createWebWorkerPairingHostRuntime", () => { pocket: false, game: true, contacts: false, + scanner: false, }); }); diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index 74a24ade1d..b201d621f4 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -38,9 +38,9 @@ use truapi::latest; use crate::platform::async_trait; use crate::platform::{ AuthPresenter, AuthState, ChainProvider, ChatPlatform, CoreStorage, CoreStorageKey, Features, - JsonRpcConnection, LocaleHost, Navigation, Notifications, PermissionDecision, Permissions, - PreimageHost, ProductContext, ProductOperations, ProductStorage, ProviderError, ThemeHost, - UserConfirmation, UserConfirmationReview, + HostScan, JsonRpcConnection, LocaleHost, Navigation, Notifications, PermissionDecision, + Permissions, PreimageHost, ProductContext, ProductOperations, ProductStorage, ProviderError, + ScannerPlatform, ThemeHost, UserConfirmation, UserConfirmationReview, }; /// How the mock answers a permission prompt for one capability. @@ -292,6 +292,8 @@ pub struct MockPlatform { /// Current theme. Seeded from the config and replaced by `set_theme`. theme: Arc>, theme_subscribers: Arc>>>, + /// What the next scan answers. Starts as a dismissal. + scan_answer: Arc>, storage_subscribers: Arc>>>>, chain_status: Arc>, @@ -338,6 +340,7 @@ impl MockPlatform { open_operations: Arc::new(Mutex::new(Vec::new())), theme: Arc::new(Mutex::new(theme)), theme_subscribers: Arc::new(Mutex::new(Vec::new())), + scan_answer: Arc::new(Mutex::new(HostScan::Dismissed)), storage_subscribers: Arc::new(Mutex::new(HashMap::new())), chain_status: Arc::new(Mutex::new(ChainStatus::Idle)), chain_disconnectors: Arc::new(Mutex::new(Vec::new())), @@ -616,6 +619,11 @@ impl MockPlatform { *self.theme.lock().expect("theme poisoned") } + /// Set what the next product scan answers. + pub fn set_scan_answer(&self, answer: HostScan) { + *self.scan_answer.lock().expect("scan answer poisoned") = answer; + } + /// Replace the reported theme and push it to every live subscriber. pub fn set_theme(&self, variant: latest::ThemeVariant) { *self.theme.lock().expect("theme poisoned") = variant; @@ -1327,6 +1335,17 @@ impl ProductOperations for MockPlatform { } } +#[async_trait] +impl ScannerPlatform for MockPlatform { + async fn scan_code( + &self, + _product: &ProductContext, + _request: &latest::HostScannerScanRequest, + ) -> Result { + Ok(self.scan_answer.lock().expect("scan answer poisoned").clone()) + } +} + #[async_trait] impl ChatPlatform for MockPlatform { async fn create_chat_room( @@ -1776,6 +1795,27 @@ mod tests { assert!(stream.next().now_or_never().is_none()); } + #[test] + fn the_scanner_answers_what_the_test_set() { + // A product's "Scan another" flow needs each scan to get its own answer. + let p = MockPlatform::new(); + let request = latest::HostScannerScanRequest { + formats: vec![latest::CodeFormat::Qr], + prefix: None, + hint: None, + }; + assert_eq!( + block_on(p.scan_code(&mock_product(), &request)), + Ok(HostScan::Dismissed) + ); + let receipt = HostScan::Scanned { + text: "https://greenmarket.example/r/1".into(), + format: latest::CodeFormat::Qr, + }; + p.set_scan_answer(receipt.clone()); + assert_eq!(block_on(p.scan_code(&mock_product(), &request)), Ok(receipt)); + } + #[test] fn theme_emits_configured_light() { let p = MockPlatform::with_config(MockConfig { diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index 8537f69b09..ee5382a77a 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -21,6 +21,7 @@ use crate::platform::SigningHostConfig; use crate::platform::{ ChainProvider, ChatPlatform, ContactsPlatform, GamePlatform, HostInfo, JsonRpcConnection, PairingHostConfig, PermissionStatusHost, PlatformInfo, PocketPlatform, ProductContext, + ScannerPlatform, ProductExecutionKind, ProviderError, RuntimeConfigValidationError, }; use futures::channel::mpsc; @@ -847,6 +848,7 @@ struct WasmPlatformAdapters { status_host: Option>, pocket_platform: Option>, game_platform: Option>, + scanner_platform: Option>, } /// Build the platform and the optional capability adapters supplied by the host. @@ -856,12 +858,14 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { let has_permission_status = bridge.has_permission_status(); let has_pocket = bridge.has_pocket(); let has_game = bridge.has_game(); + let has_scanner = bridge.has_scanner(); let platform = Arc::new(WasmPlatform::new(bridge)); let chat = has_chat.then(|| platform.clone() as Arc); let contacts = has_contacts.then(|| platform.clone() as Arc); let status = has_permission_status.then(|| platform.clone() as Arc); let pocket = has_pocket.then(|| platform.clone() as Arc); let game = has_game.then(|| platform.clone() as Arc); + let scanner = has_scanner.then(|| platform.clone() as Arc); WasmPlatformAdapters { platform, chat_platform: chat, @@ -869,6 +873,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { status_host: status, pocket_platform: pocket, game_platform: game, + scanner_platform: scanner, } } @@ -933,6 +938,7 @@ impl WasmPairingHostRuntime { status_host, pocket_platform, game_platform, + scanner_platform, } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); @@ -954,6 +960,9 @@ impl WasmPairingHostRuntime { if let Some(game_platform) = game_platform { runtime.set_game_platform(game_platform); } + if let Some(scanner_platform) = scanner_platform { + runtime.set_scanner_platform(scanner_platform); + } install_worker_demand_observer(runtime.worker_ledger(), &callbacks)?; Ok(Self { runtime: Rc::new(runtime), @@ -1261,6 +1270,7 @@ impl WasmSigningHostRuntime { platform, pocket_platform, game_platform, + scanner_platform, .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { @@ -1274,6 +1284,9 @@ impl WasmSigningHostRuntime { if let Some(game_platform) = game_platform { runtime.set_game_platform(game_platform); } + if let Some(scanner_platform) = scanner_platform { + runtime.set_scanner_platform(scanner_platform); + } install_worker_demand_observer(runtime.worker_ledger(), &callbacks)?; Ok(Self { runtime: Rc::new(runtime), @@ -1469,6 +1482,7 @@ impl WasmProductRuntime { status_host, pocket_platform, game_platform, + scanner_platform, } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); @@ -1487,6 +1501,9 @@ impl WasmProductRuntime { if let Some(game_platform) = game_platform { pairing.set_game_platform(game_platform); } + if let Some(scanner_platform) = scanner_platform { + pairing.set_scanner_platform(scanner_platform); + } if let Some(contacts_platform) = contacts_platform { pairing.set_contacts_platform(contacts_platform); } From a9bb9f2c5f2c12302c9364e979921baf59790b4f Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:48:04 -0300 Subject: [PATCH 09/29] feat(host-cli): answer product scans from the environment Both CLI host roles serve the scanner. A scan answers TRUAPI_SCAN_TEXT as a QR code, or a dismissal when it is unset, so the battery now runs the Scanner example instead of skipping it. --- README.md | 6 +- rust/crates/truapi-host-cli/README.md | 5 ++ .../truapi-host-cli/js/diagnosis.test.ts | 1 - rust/crates/truapi-host-cli/js/diagnosis.ts | 2 +- rust/crates/truapi-host-cli/src/main.rs | 3 + rust/crates/truapi-host-cli/src/scanner.rs | 58 +++++++++++++++++++ 6 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 rust/crates/truapi-host-cli/src/scanner.rs diff --git a/README.md b/README.md index 7b6c19a02e..019cb040ee 100644 --- a/README.md +++ b/README.md @@ -328,9 +328,9 @@ withdrawn before it reaches the host, the absence of any permission prompt, and host failures. `Scanner` (`scan`) opens the host's own QR and barcode viewfinder (RFC -"Host-drawn scanner"). No host serves it yet, so the runtime -answers `Unsupported`, and the battery and the playground's Diagnosis both skip -the service. +"Host-drawn scanner"). Both CLI host roles answer it from `TRUAPI_SCAN_TEXT`, +or with a dismissal when it is unset, so the battery runs it. The playground's +Diagnosis skips it, since dot.li has no viewfinder and answers `Unsupported`. To run the playground locally in a plain browser tab, against a signing host on your own machine: diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 85b9ea967e..a61be3431b 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -782,6 +782,11 @@ Scripts under `js/scripts/` include: the picker offers; the approval surface asks about it like any other action, so a headless run approves it and an interactive one does not. + The CLI has no camera, so `scanner.scan` answers with `TRUAPI_SCAN_TEXT` as a + QR code, or as a dismissal when it is unset. The core still checks the code + against the product's request, so a text that misses the prefix answers + `Unknown`. + The paired phase gives its pairing host a throwaway `--base-path` under `target/battery/pairing-host-state`, so it performs a real handshake on every run. A pairing host that restores an earlier session reports diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index ddba9df520..3d558fcf06 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -53,7 +53,6 @@ describe("generated-example battery", () => { const unsupported = new Map([ ["Coin Payment", "Coin Payment service not yet wired up by hosts"], ["Payment", "Payment service not yet wired up by hosts"], - ["Scanner", "Scanner service not yet wired up by hosts"], [ "Game", "Game serves only the game product; the truapi runtime tests cover it", diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts index ed91abf990..525daf6f55 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -19,7 +19,7 @@ const UNARY_TIMEOUT_MS = 10_000; const REMOTE_RESPONSE_TIMEOUT_MS = 190_000; const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; const APP_SERVICES = servicesForExecution(services, "App"); -const UNWIRED_SERVICES = ["Coin Payment", "Payment", "Scanner"]; +const UNWIRED_SERVICES = ["Coin Payment", "Payment"]; const SKIPPED_SERVICES = new Map([ ...UNWIRED_SERVICES.map( (name) => [name, `${name} service not yet wired up by hosts`] as const, diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 7c5cf8263c..2aa940a2bf 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -28,6 +28,7 @@ mod pocket; mod product_config; mod qr_scanner; mod register_name; +mod scanner; mod script_project; mod script_runner; mod sessions; @@ -1255,6 +1256,7 @@ async fn run_pairing_host( pairing_runtime.set_pocket_platform(pocket); } pairing_runtime.set_game_platform(Arc::new(game::CliGameHost)); + pairing_runtime.set_scanner_platform(Arc::new(scanner::CliScannerHost)); pairing_runtime.set_contacts_platform(contacts::CliContactsHost::from_env( storage_platform.clone(), )); @@ -1819,6 +1821,7 @@ fn build_signing_runtime( runtime.set_pocket_platform(pocket); } runtime.set_game_platform(Arc::new(game::CliGameHost)); + runtime.set_scanner_platform(Arc::new(scanner::CliScannerHost)); runtime.set_core_db(core_db); runtime.start_statement_allowance_renewal(); Ok((runtime, platform)) diff --git a/rust/crates/truapi-host-cli/src/scanner.rs b/rust/crates/truapi-host-cli/src/scanner.rs new file mode 100644 index 0000000000..37ac90f81b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/scanner.rs @@ -0,0 +1,58 @@ +//! Scanner host for the CLI. +//! +//! A CLI has no camera, so the code it scans comes from `TRUAPI_SCAN_TEXT`, +//! read on every scan and reported as a QR code. With nothing set the user +//! dismisses, which lets the generated example and the battery pass on a bare +//! host. The host does not filter: the core refuses a configured code the +//! request does not accept. + +use truapi::latest::{CodeFormat, GenericError, HostScannerScanRequest}; +use truapi::platform::{HostScan, ProductContext, ScannerPlatform, async_trait}; + +/// A scanner that answers every scan from the environment. +pub struct CliScannerHost; + +#[async_trait] +impl ScannerPlatform for CliScannerHost { + async fn scan_code( + &self, + product: &ProductContext, + _request: &HostScannerScanRequest, + ) -> Result { + let answer = answer(std::env::var("TRUAPI_SCAN_TEXT").ok()); + tracing::info!(product = %product.product_id, ?answer, "scan answered"); + Ok(answer) + } +} + +fn answer(text: Option) -> HostScan { + match text { + Some(text) => HostScan::Scanned { + text, + format: CodeFormat::Qr, + }, + None => HostScan::Dismissed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn with_nothing_set_the_user_dismisses() { + // The generated example and the battery run on a bare CLI. + assert_eq!(answer(None), HostScan::Dismissed); + } + + #[test] + fn a_set_text_is_scanned_as_a_qr_code() { + assert_eq!( + answer(Some("https://greenmarket.example/r/1".into())), + HostScan::Scanned { + text: "https://greenmarket.example/r/1".into(), + format: CodeFormat::Qr, + } + ); + } +} From 16de425b19349fc4f2bf30305ff7e61a56ed7e21 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:48:19 -0300 Subject: [PATCH 10/29] docs: describe the scanner capability and add its changeset --- .changeset/host-scanner-core.md | 6 ++++++ rust/crates/truapi/RUNTIME.md | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .changeset/host-scanner-core.md diff --git a/.changeset/host-scanner-core.md b/.changeset/host-scanner-core.md new file mode 100644 index 0000000000..d6c2ae5b4a --- /dev/null +++ b/.changeset/host-scanner-core.md @@ -0,0 +1,6 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Serve `scanner.scan`. A host supplies the optional `scanner` callbacks to draw its viewfinder, and the core checks each request and each answer. The mock test host serves a scanner when created with a `scanner` answer, and `setScanAnswer` changes the next one. `contacts.pick` now stops waiting when the product cancels. diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 48692deb17..02b35cbf8f 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -413,17 +413,27 @@ AutoSigning without approval. Legacy-account signing still asks the user. one and delivers a notification otherwise, may add a calendar event, and keeps the reminder across app kill and reboot. A schedule the host cannot hold fails as a host failure carrying its reason. +- `ScannerPlatform`: open the host's QR and barcode viewfinder for a product + and report how it ended. The core checks the request first, answering + `Unsupported` without a scanner, `InvalidRequest` for a request over its + limits, and `Busy` while another scan is open, since the device has one + viewfinder. It then calls the host and checks the code again, so a code the + request does not accept reaches the product as `Unknown`. A cancelled call + drops the host's future, which is the host's signal to close the viewfinder. + The host filters codes with the core's `ScanFilter` and never follows a + scanned link. `Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, `ContactsPlatform`, `PermissionStatusHost`, -`PocketPlatform` and `GamePlatform`, which `OptionalPlatform` lists instead: a +`PocketPlatform`, `GamePlatform` and `ScannerPlatform`, which `OptionalPlatform` +lists instead: a host supplies each only when it can serve it. Codegen reads `OptionalPlatform` to emit each listed capability as an optional group on the host-callback surface. Omitting `ChatPlatform` makes the core answer Chat calls `Unsupported`, and -omitting `ContactsPlatform`, `PocketPlatform` or `GamePlatform` does the same -for Contacts, Pocket or Game calls. +omitting `ContactsPlatform`, `PocketPlatform`, `GamePlatform` or +`ScannerPlatform` does the same for Contacts, Pocket, Game or Scanner calls. Omitting `PermissionStatusHost` leaves device grants resolving from stored state alone, which is what a host with no OS permission model does anyway. Serving it gates both halves of the surface: a device permission request and a From 505cfa5ce3086b0a786287272c9fe8e0f189bb97 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:51:38 -0300 Subject: [PATCH 11/29] style: format the scanner code that cargo fmt does not reach These modules are declared inside the runtime_items! macro, so cargo fmt skips them. Formatted with rustfmt directly, touching only lines this branch added. --- rust/crates/truapi/src/host_logic/scanner.rs | 40 ++++++++++++++++---- rust/crates/truapi/src/native.rs | 2 +- rust/crates/truapi/src/platform.rs | 10 ++--- rust/crates/truapi/src/platform/mock.rs | 11 +++++- rust/crates/truapi/src/runtime/tests.rs | 17 +++++++-- rust/crates/truapi/src/wasm.rs | 3 +- 6 files changed, 62 insertions(+), 21 deletions(-) diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index 2de54c2b77..8643fb3c66 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -182,16 +182,36 @@ mod tests { #[test] fn a_code_is_accepted_only_in_a_named_format() { assert!(accepts(&qr(None, None), CodeFormat::Qr, "anything")); - assert!(!accepts(&qr(None, None), CodeFormat::Ean13, "4006381333931")); + assert!(!accepts( + &qr(None, None), + CodeFormat::Ean13, + "4006381333931" + )); } #[test] fn the_prefix_ignores_letter_case_and_nothing_else() { // QR codes often store URLs in capitals to fit more in the code. - assert!(accepts(&receipts(), CodeFormat::Qr, "https://greenmarket.example/r/BAG6")); - assert!(accepts(&receipts(), CodeFormat::Qr, "HTTPS://GREENMARKET.EXAMPLE/R/BAG6")); - assert!(!accepts(&receipts(), CodeFormat::Qr, "polkadotapp://pair?handshake=00")); - assert!(!accepts(&receipts(), CodeFormat::Qr, "https://greenmarket.example")); + assert!(accepts( + &receipts(), + CodeFormat::Qr, + "https://greenmarket.example/r/BAG6" + )); + assert!(accepts( + &receipts(), + CodeFormat::Qr, + "HTTPS://GREENMARKET.EXAMPLE/R/BAG6" + )); + assert!(!accepts( + &receipts(), + CodeFormat::Qr, + "polkadotapp://pair?handshake=00" + )); + assert!(!accepts( + &receipts(), + CodeFormat::Qr, + "https://greenmarket.example" + )); } #[test] @@ -205,7 +225,10 @@ mod tests { // The viewfinder sees the same code on many frames before it closes. let filter = ScanFilter::new(receipts()); let receipt = "https://greenmarket.example/r/BAG6".to_owned(); - assert_eq!(filter.observe(CodeFormat::Qr, receipt.clone()), ScanVerdict::Accept); + assert_eq!( + filter.observe(CodeFormat::Qr, receipt.clone()), + ScanVerdict::Accept + ); assert_eq!(filter.observe(CodeFormat::Qr, receipt), ScanVerdict::Ignore); } @@ -219,7 +242,10 @@ mod tests { filter.observe(CodeFormat::Qr, poster.clone()), ScanVerdict::NotForThisProduct ); - assert_eq!(filter.observe(CodeFormat::Qr, poster.clone()), ScanVerdict::Ignore); + assert_eq!( + filter.observe(CodeFormat::Qr, poster.clone()), + ScanVerdict::Ignore + ); assert_eq!( filter.observe(CodeFormat::Qr, pairing), ScanVerdict::NotForThisProduct diff --git a/rust/crates/truapi/src/native.rs b/rust/crates/truapi/src/native.rs index c8bcf06a95..57a200d606 100644 --- a/rust/crates/truapi/src/native.rs +++ b/rust/crates/truapi/src/native.rs @@ -28,11 +28,11 @@ mod ws_bridge; pub use crate::host_internal::sso_messages::SsoRequestOutcome; pub use crate::host_logic::dotns::{NavigateDecision, PocketDeeplinkAction}; +pub use crate::host_logic::scanner::{ScanFilter, ScanVerdict}; pub use callbacks::{ HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeGameCallbacks, NativePocketCallbacks, NativePocketRemoval, NativeScannerCallbacks, }; -pub use crate::host_logic::scanner::{ScanFilter, ScanVerdict}; pub use config::{HostRuntimeConfig, NativeRuntimeConfigError, ProductExecutionConfig}; pub use errors::{HostRejection, NativeChatFieldError, NativeCoreDatabaseError, NativeRendererError}; pub use renderer::{NativeRendererObserver, NativeRendererSubscription}; diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 7801028586..41353bb5ba 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -35,11 +35,11 @@ use truapi::latest::{ HostFeatureSupportedResponse, HostLocalStorageChangeItem, HostLocaleSubscribeItem, HostNavigateToError, HostPlatform, HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPushNotificationRequest, HostPushNotificationResponse, - HostScannerScanRequest, - HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, - HostWorkerOperationError, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, - ProductProofContext, RemotePermission, RemotePermissionRequest, RingLocation, + HostScannerScanRequest, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, + HostWorkerBeginOperationResponse, HostWorkerOperationError, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, ProductProofContext, RemotePermission, + RemotePermissionRequest, RingLocation, }; use truapi::v01::HostAccountSignVrfRequest; use url::{Host, Url}; diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index b201d621f4..bec721cfe4 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -1342,7 +1342,11 @@ impl ScannerPlatform for MockPlatform { _product: &ProductContext, _request: &latest::HostScannerScanRequest, ) -> Result { - Ok(self.scan_answer.lock().expect("scan answer poisoned").clone()) + Ok(self + .scan_answer + .lock() + .expect("scan answer poisoned") + .clone()) } } @@ -1813,7 +1817,10 @@ mod tests { format: latest::CodeFormat::Qr, }; p.set_scan_answer(receipt.clone()); - assert_eq!(block_on(p.scan_code(&mock_product(), &request)), Ok(receipt)); + assert_eq!( + block_on(p.scan_code(&mock_product(), &request)), + Ok(receipt) + ); } #[test] diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index eff8655079..3ba12ee5e0 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7802,7 +7802,9 @@ fn unwrap_scan( } } -fn scan(host: &ProductRuntimeHost) -> Result> { +fn scan( + host: &ProductRuntimeHost, +) -> Result> { scan_in(host, &CallContext::default()) } @@ -7857,7 +7859,9 @@ fn a_dismissal_is_an_outcome_and_a_missing_camera_is_an_error() { ))); assert_eq!( scan(&no_camera), - Err(CallError::Domain(v01::HostScannerScanError::CameraUnavailable)) + Err(CallError::Domain( + v01::HostScannerScanError::CameraUnavailable + )) ); } @@ -7877,7 +7881,9 @@ fn an_invalid_request_never_reaches_the_host() { assert!(matches!( result, - Err(CallError::Domain(v01::HostScannerScanError::InvalidRequest { .. })) + Err(CallError::Domain( + v01::HostScannerScanError::InvalidRequest { .. } + )) )); assert!(scanner.asked().is_empty()); } @@ -7888,7 +7894,10 @@ fn a_host_answer_the_request_does_not_accept_never_reaches_the_product() { // right text in a format it did not ask for. for answer in [ scanned("polkadotapp://pair?handshake=00", v01::CodeFormat::Qr), - scanned("https://greenmarket.example/r/BAG6", v01::CodeFormat::Code128), + scanned( + "https://greenmarket.example/r/BAG6", + v01::CodeFormat::Code128, + ), ] { let host = scanner_host(Some(StubScannerPlatform::answering(answer))); assert!(matches!( diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index ee5382a77a..e3e95ab5bd 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -21,8 +21,7 @@ use crate::platform::SigningHostConfig; use crate::platform::{ ChainProvider, ChatPlatform, ContactsPlatform, GamePlatform, HostInfo, JsonRpcConnection, PairingHostConfig, PermissionStatusHost, PlatformInfo, PocketPlatform, ProductContext, - ScannerPlatform, - ProductExecutionKind, ProviderError, RuntimeConfigValidationError, + ProductExecutionKind, ProviderError, RuntimeConfigValidationError, ScannerPlatform, }; use futures::channel::mpsc; use futures::future::{AbortHandle, Abortable}; From d65a87a31e8f62d5fd952e36541045760a7a092d Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 12:53:53 -0300 Subject: [PATCH 12/29] test(host-cli): check the scanner end to end against a CLI host scripts/battery.sh --scanner-host (make e2e-scanner-cli) starts a signing host that scans a fixed code and checks, over the real wire and without a session, that a matching code reaches the product, that a code or format the product did not ask for never does, and that an invalid request is refused before the host is asked. --- Makefile | 5 +- README.md | 5 ++ rust/crates/truapi-host-cli/js/scanner-e2e.ts | 90 +++++++++++++++++++ .../js/scripts/scanner-battery.ts | 31 +++++++ scripts/battery.sh | 34 ++++++- 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 rust/crates/truapi-host-cli/js/scanner-e2e.ts create mode 100644 rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts diff --git a/Makefile b/Makefile index 4ee1897e15..bc0c7bcb0d 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap debugger dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-pocket-cli e2e-cross-product-storage e2e-cross-product-ringvrf e2e-cross-product-signing e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework +.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap debugger dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-pocket-cli e2e-scanner-cli e2e-cross-product-storage e2e-cross-product-ringvrf e2e-cross-product-signing e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework CARGO ?= cargo # The dated nightly CI runs; see nightly-toolchain. @@ -464,6 +464,9 @@ e2e-chat-cli: ## Run the Chat content-screening battery against a chat signing-h e2e-pocket-cli: ## Run the Pocket protocol battery against a Pocket signing-host CLI. scripts/battery.sh --pocket-host +e2e-scanner-cli: ## Run the scanner checks against a signing-host CLI that scans a fixed code. + scripts/battery.sh --scanner-host + e2e-cross-product-storage: ## One product reads another's storage on the signing-host CLI, granted by a local product config. scripts/cross-product-storage-e2e.sh diff --git a/README.md b/README.md index 019cb040ee..712b879bd9 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ make e2e-signing-cli # same direct signing-host phase make e2e-pairing-cli # same paired pairing-host phase make e2e-chat-cli # chat content screening against a chat signing-host make e2e-pocket-cli # Pocket protocol check against a Pocket signing-host +make e2e-scanner-cli # scanner checks against a signing-host that scans a fixed code ``` The Pocket phase runs its product as a Worker execution, the only execution @@ -331,6 +332,10 @@ host failures. "Host-drawn scanner"). Both CLI host roles answer it from `TRUAPI_SCAN_TEXT`, or with a dismissal when it is unset, so the battery runs it. The playground's Diagnosis skips it, since dot.li has no viewfinder and answers `Unsupported`. +`make e2e-scanner-cli` runs the scanner phase: a signing host scans +`https://greenmarket.example/r/BAG6`, and the cases check that a matching code +reaches the product, that a code or format the product did not ask for never +does, and that an invalid request is refused before the host is asked. To run the playground locally in a plain browser tab, against a signing host on your own machine: diff --git a/rust/crates/truapi-host-cli/js/scanner-e2e.ts b/rust/crates/truapi-host-cli/js/scanner-e2e.ts new file mode 100644 index 0000000000..d147a9ceab --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scanner-e2e.ts @@ -0,0 +1,90 @@ +// End-to-end scanner checks, run against a CLI host that answers every scan +// with `TRUAPI_SCAN_TEXT` as a QR code. +// +// The CLI does not filter, so every case that refuses a code proves the core +// checked the host's answer, and every case that refuses a request proves the +// core checked it before asking the host. +import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; +import type { + CodeFormat, + HostScannerScanRequest, +} from "../../../../js/packages/truapi/src/generated/types.ts"; + +/** What the host scans in every case. */ +export const SCANNED_TEXT = "https://greenmarket.example/r/BAG6"; + +/** One case and how it ended. */ +export interface ScannerRow { + name: string; + pass: boolean; + output: string; +} + +type Expected = + | { scanned: { text: string; format: CodeFormat } } + | { domainError: "InvalidRequest" | "Unknown" }; + +interface Case { + name: string; + request: HostScannerScanRequest; + expected: Expected; +} + +const CASES: Case[] = [ + { + name: "a code that matches the request reaches the product", + request: { + formats: ["Qr"], + prefix: "https://greenmarket.example/r/", + hint: "Point at the receipt's QR code", + }, + expected: { scanned: { text: SCANNED_TEXT, format: "Qr" } }, + }, + { + name: "the prefix ignores letter case", + request: { formats: ["Qr"], prefix: "HTTPS://GREENMARKET.EXAMPLE/R/" }, + expected: { scanned: { text: SCANNED_TEXT, format: "Qr" } }, + }, + { + name: "a code without the prefix never reaches the product", + request: { formats: ["Qr"], prefix: "polkadotapp://pair" }, + expected: { domainError: "Unknown" }, + }, + { + name: "a code in a format the product did not ask for never reaches it", + request: { formats: ["Ean13"] }, + expected: { domainError: "Unknown" }, + }, + { + name: "a hint on two lines is refused before the host is asked", + request: { formats: ["Qr"], hint: "Scan the code on your computer\nto sign in" }, + expected: { domainError: "InvalidRequest" }, + }, + { + name: "a request with no formats is refused before the host is asked", + request: { formats: [] }, + expected: { domainError: "InvalidRequest" }, + }, +]; + +/** Run every case against `truapi`, which must not be signed in. */ +export async function runScannerE2e(truapi: TrUApiClient): Promise { + const rows: ScannerRow[] = []; + for (const { name, request, expected } of CASES) { + const result = await truapi.scanner.scan(request); + const actual = result.isOk() + ? JSON.stringify(result.value.outcome) + : JSON.stringify(result.error); + const pass = + "scanned" in expected + ? result.isOk() && + result.value.outcome.tag === "Scanned" && + result.value.outcome.value.text === expected.scanned.text && + result.value.outcome.value.format === expected.scanned.format + : result.isErr() && + result.error.tag === "Domain" && + result.error.value.value.tag === expected.domainError; + rows.push({ name, pass, output: actual }); + } + return rows; +} diff --git a/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts b/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts new file mode 100644 index 0000000000..851acb161c --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts @@ -0,0 +1,31 @@ +/// +// Scanner checks against a real host, over the real wire. +// +// Run via: +// scripts/battery.sh --scanner-host +// +// which starts a signing host with `TRUAPI_SCAN_TEXT` set. The product never +// signs in, because scanning reads no account. +import { runScannerE2e, SCANNED_TEXT } from "../scanner-e2e.ts"; + +if (process.env.TRUAPI_SCAN_TEXT !== SCANNED_TEXT) { + throw new Error( + `scanner battery needs TRUAPI_SCAN_TEXT=${SCANNED_TEXT}; run it through scripts/battery.sh --scanner-host`, + ); +} + +const rows = await runScannerE2e(truapi); +for (const row of rows) { + console.log(`${row.pass ? "✅" : "❌"} ${row.name}: ${row.output}`); +} + +const failures = rows.filter((row) => !row.pass); +if (failures.length > 0) { + throw new Error( + `scanner battery failed: ${failures.length} of ${rows.length} cases\n${failures + .map((row) => `${row.name}: ${row.output}`) + .join("\n")}`, + ); +} + +console.log(`scanner battery: ${rows.length} cases passed`); diff --git a/scripts/battery.sh b/scripts/battery.sh index e081b77ff8..c109a14bf2 100755 --- a/scripts/battery.sh +++ b/scripts/battery.sh @@ -11,6 +11,7 @@ # scripts/battery.sh --signing-host # direct phase only # scripts/battery.sh --pairing-host # paired phase only # scripts/battery.sh --pocket-host # Pocket phase only +# scripts/battery.sh --scanner-host # scanner phase only # scripts/battery.sh --release # build and run the release binary # scripts/battery.sh -- --network foo # arguments after `--` go to every host process # @@ -48,6 +49,7 @@ unset DYLD_LIBRARY_PATH SCRIPT="rust/crates/truapi-host-cli/js/scripts/battery.ts" CHAT_SCRIPT="rust/crates/truapi-host-cli/js/scripts/chat-battery.ts" POCKET_SCRIPT="rust/crates/truapi-host-cli/js/scripts/pocket-battery.ts" +SCANNER_SCRIPT="rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts" PRODUCT_ID="truapi-playground.dot" REPORTS="explorer/diagnosis-reports/spa" LOG_DIR="target/battery" @@ -61,6 +63,7 @@ RUN_SIGNING=1 RUN_PAIRING=1 RUN_CHAT=0 RUN_POCKET=0 +RUN_SCANNER=0 usage() { awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" @@ -76,6 +79,9 @@ while [ $# -gt 0 ]; do # Pocket is its own phase for the same reason: it needs a Worker-execution # connection with a seeded card set, which no other phase opens. --pocket-host) RUN_SIGNING=0; RUN_PAIRING=0; RUN_POCKET=1 ;; + # The scanner is its own phase because the host needs TRUAPI_SCAN_TEXT set, + # and the generated battery expects a dismissal from a host without it. + --scanner-host) RUN_SIGNING=0; RUN_PAIRING=0; RUN_SCANNER=1 ;; --release) CARGO_ARGS+=(--release); PROFILE_DIR="release" ;; --product-id) [ $# -ge 2 ] || { echo "battery: --product-id needs a value" >&2; exit 2; } @@ -242,6 +248,25 @@ pocket_phase() { return "$rc" } +scanner_phase() { + local log="$LOG_DIR/scanner-host-cli.log" + echo "battery: scanner phase" + # The code the CLI scans in every case. The cases refuse it on purpose too, + # which only the core can do, since the CLI does not filter. + export TRUAPI_SCAN_TEXT="https://greenmarket.example/r/BAG6" + "$HOST" signing-host \ + --product-id "$PRODUCT_ID" \ + --script "$SCANNER_SCRIPT" \ + --auto-accept \ + ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 & + local host_pid=$! rc=0 + start_watchdog "$host_pid" "scanner phase" + wait "$host_pid" || rc=$? + stop_watchdog + unset TRUAPI_SCAN_TEXT + return "$rc" +} + pairing_phase() { local log="$LOG_DIR/pairing-host-cli.log" local signer_log="$LOG_DIR/pairing-host-cli-signer.log" @@ -316,6 +341,7 @@ SIGNING_RC="skipped" PAIRING_RC="skipped" CHAT_RC="skipped" POCKET_RC="skipped" +SCANNER_RC="skipped" # Each phase writes under the directory for the modality it exercises, so the # summary names the ones this run actually produced rather than always the App @@ -354,10 +380,16 @@ if [ "$RUN_POCKET" = 1 ]; then pocket_phase || POCKET_RC=$? fi +if [ "$RUN_SCANNER" = 1 ]; then + SCANNER_RC=0 + scanner_phase || SCANNER_RC=$? +fi + echo -echo "battery: signing-host exit=$SIGNING_RC · pairing-host exit=$PAIRING_RC · chat-host exit=$CHAT_RC · pocket-host exit=$POCKET_RC" +echo "battery: signing-host exit=$SIGNING_RC · pairing-host exit=$PAIRING_RC · chat-host exit=$CHAT_RC · pocket-host exit=$POCKET_RC · scanner-host exit=$SCANNER_RC" echo "battery: reports under $(report_dirs), logs under $LOG_DIR/" [ "$SIGNING_RC" = 0 ] || [ "$SIGNING_RC" = "skipped" ] || exit "$SIGNING_RC" [ "$PAIRING_RC" = 0 ] || [ "$PAIRING_RC" = "skipped" ] || exit "$PAIRING_RC" [ "$CHAT_RC" = 0 ] || [ "$CHAT_RC" = "skipped" ] || exit "$CHAT_RC" [ "$POCKET_RC" = 0 ] || [ "$POCKET_RC" = "skipped" ] || exit "$POCKET_RC" +[ "$SCANNER_RC" = 0 ] || [ "$SCANNER_RC" = "skipped" ] || exit "$SCANNER_RC" From 78a18d62f84e40fff6c491eb488ed9d86cbe1996 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 13:02:46 -0300 Subject: [PATCH 13/29] test(host-cli): run the scanner checks on the pairing host too Scanning needs no session, so the pairing host runs the same cases unpaired. --- Makefile | 2 +- README.md | 3 ++- scripts/battery.sh | 30 ++++++++++++++++++++---------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index bc0c7bcb0d..c9278cdfb9 100644 --- a/Makefile +++ b/Makefile @@ -464,7 +464,7 @@ e2e-chat-cli: ## Run the Chat content-screening battery against a chat signing-h e2e-pocket-cli: ## Run the Pocket protocol battery against a Pocket signing-host CLI. scripts/battery.sh --pocket-host -e2e-scanner-cli: ## Run the scanner checks against a signing-host CLI that scans a fixed code. +e2e-scanner-cli: ## Run the scanner checks against both CLI host roles, each scanning a fixed code. scripts/battery.sh --scanner-host e2e-cross-product-storage: ## One product reads another's storage on the signing-host CLI, granted by a local product config. diff --git a/README.md b/README.md index 712b879bd9..f149be1e89 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,8 @@ host failures. "Host-drawn scanner"). Both CLI host roles answer it from `TRUAPI_SCAN_TEXT`, or with a dismissal when it is unset, so the battery runs it. The playground's Diagnosis skips it, since dot.li has no viewfinder and answers `Unsupported`. -`make e2e-scanner-cli` runs the scanner phase: a signing host scans +`make e2e-scanner-cli` runs the scanner phase on both CLI host roles, the +pairing host unpaired since scanning needs no session. The host scans `https://greenmarket.example/r/BAG6`, and the cases check that a matching code reaches the product, that a code or format the product did not ask for never does, and that an invalid request is refused before the host is asked. diff --git a/scripts/battery.sh b/scripts/battery.sh index c109a14bf2..a5f5155a10 100755 --- a/scripts/battery.sh +++ b/scripts/battery.sh @@ -249,20 +249,30 @@ pocket_phase() { } scanner_phase() { - local log="$LOG_DIR/scanner-host-cli.log" echo "battery: scanner phase" # The code the CLI scans in every case. The cases refuse it on purpose too, # which only the core can do, since the CLI does not filter. export TRUAPI_SCAN_TEXT="https://greenmarket.example/r/BAG6" - "$HOST" signing-host \ - --product-id "$PRODUCT_ID" \ - --script "$SCANNER_SCRIPT" \ - --auto-accept \ - ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 & - local host_pid=$! rc=0 - start_watchdog "$host_pid" "scanner phase" - wait "$host_pid" || rc=$? - stop_watchdog + local role rc=0 + # Scanning needs no session, so the pairing host runs unpaired, from a fresh + # base path so no earlier session is restored. + for role in signing-host pairing-host; do + local log="$LOG_DIR/scanner-$role-cli.log" role_args=() + if [ "$role" = pairing-host ]; then + rm -rf "$LOG_DIR/scanner-pairing-host-state" + role_args=(--base-path "$LOG_DIR/scanner-pairing-host-state") + fi + "$HOST" "$role" \ + --product-id "$PRODUCT_ID" \ + --script "$SCANNER_SCRIPT" \ + --auto-accept \ + ${role_args[@]+"${role_args[@]}"} \ + ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 & + local host_pid=$! + start_watchdog "$host_pid" "scanner phase ($role)" + wait "$host_pid" || rc=$? + stop_watchdog + done unset TRUAPI_SCAN_TEXT return "$rc" } From e71ec5f87fef3cfe4d86757a3dd3d84f64e00af0 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 13:11:37 -0300 Subject: [PATCH 14/29] fix(truapi): answer an internal cancel with the method's own error Review of #1307: - scanner.scan and contacts.pick turned any cancelled wait into CallError::Cancelled. Only the dispatcher may answer that, for a call the product withdrew, so both now answer Unknown with the reason. - ScanFilter names each wrong code once per scan, so two codes read in turn no longer repeat the message on every frame. - Hints may not carry U+061C, the last bidirectional mark missing. - until_cancelled is private again, and services use core atomics. - ScannerPlatform says a new scan can arrive before the host has closed a cancelled one, and that a JS host is not told about a cancel. --- .../tests/golden/host-callbacks.ts | 8 ++-- rust/crates/truapi/src/host_logic/scanner.rs | 43 ++++++++----------- rust/crates/truapi/src/platform.rs | 8 ++-- rust/crates/truapi/src/runtime.rs | 8 +++- .../src/runtime/capabilities/scanner.rs | 6 ++- rust/crates/truapi/src/runtime/services.rs | 2 +- rust/crates/truapi/src/runtime/tests.rs | 17 ++++++-- 7 files changed, 54 insertions(+), 38 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index a7b1fd46db..4f8693422c 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -1709,9 +1709,11 @@ export interface ProductStorage { * The host draws the viewfinder and titles it with the requesting product's * id. Only `request.hint` is product text. It hands every code the camera * reads to the core's `ScanFilter` and acts on its verdict, and never follows - * a scanned link itself. The core drops - * the returned future when the product cancels, and the host closes the - * viewfinder then. + * a scanned link itself. The core drops the returned future when the product + * cancels, and the host closes the viewfinder then. The next scan can arrive + * before that close has finished, so close any viewfinder still open before + * opening another. A JS host is not told about a cancel, because a promise + * cannot be withdrawn. */ export interface ScannerPlatform { /** diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index 8643fb3c66..7b38d1519d 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -4,6 +4,7 @@ //! filter codes in the viewfinder with [`ScanFilter`], so every host accepts //! the same codes. +use std::collections::BTreeSet; use std::sync::Mutex; use truapi::latest::{CodeFormat, HostScannerScanRequest}; @@ -42,6 +43,7 @@ fn breaks_the_line(character: char) -> bool { character, '\u{2028}' | '\u{2029}' + | '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' @@ -80,7 +82,7 @@ pub struct ScanFilter { #[derive(Default)] struct FilterState { accepted: bool, - last_refused: Option<(CodeFormat, String)>, + refused: BTreeSet<(u8, String)>, } #[cfg_attr(not(target_arch = "wasm32"), uniffi::export)] @@ -104,12 +106,11 @@ impl ScanFilter { state.accepted = true; return ScanVerdict::Accept; } - let code = Some((format, text)); - if state.last_refused == code { - return ScanVerdict::Ignore; + if state.refused.insert((format as u8, text)) { + ScanVerdict::NotForThisProduct + } else { + ScanVerdict::Ignore } - state.last_refused = code; - ScanVerdict::NotForThisProduct } } @@ -163,6 +164,7 @@ mod tests { "\u{202E}reversed", "\u{2066}isolate", "\u{200F}mark", + "\u{061C}arabic letter mark", ] { assert!( validate_request(&qr(None, Some(refused))).is_err(), @@ -233,27 +235,20 @@ mod tests { } #[test] - fn the_filter_names_a_wrong_code_once_while_it_stays_in_view() { - // One message per wrong code, not one per camera frame. + fn the_filter_names_each_wrong_code_once_per_scan() { + // A poster with two codes on it is read alternately, frame after frame. + // The user should hear about each once, not on every frame. let filter = ScanFilter::new(receipts()); let poster = "https://elsewhere.example".to_owned(); let pairing = "polkadotapp://pair?handshake=00".to_owned(); - assert_eq!( - filter.observe(CodeFormat::Qr, poster.clone()), - ScanVerdict::NotForThisProduct - ); - assert_eq!( - filter.observe(CodeFormat::Qr, poster.clone()), - ScanVerdict::Ignore - ); - assert_eq!( - filter.observe(CodeFormat::Qr, pairing), - ScanVerdict::NotForThisProduct - ); - assert_eq!( - filter.observe(CodeFormat::Qr, poster), - ScanVerdict::NotForThisProduct - ); + for (code, expected) in [ + (&poster, ScanVerdict::NotForThisProduct), + (&pairing, ScanVerdict::NotForThisProduct), + (&poster, ScanVerdict::Ignore), + (&pairing, ScanVerdict::Ignore), + ] { + assert_eq!(filter.observe(CodeFormat::Qr, code.clone()), expected); + } } #[test] diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 41353bb5ba..ce8ead699d 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3518,9 +3518,11 @@ pub enum HostScan { /// The host draws the viewfinder and titles it with the requesting product's /// id. Only `request.hint` is product text. It hands every code the camera /// reads to the core's `ScanFilter` and acts on its verdict, and never follows -/// a scanned link itself. The core drops -/// the returned future when the product cancels, and the host closes the -/// viewfinder then. +/// a scanned link itself. The core drops the returned future when the product +/// cancels, and the host closes the viewfinder then. The next scan can arrive +/// before that close has finished, so close any viewfinder still open before +/// opening another. A JS host is not told about a cancel, because a promise +/// cannot be withdrawn. #[async_trait] pub trait ScannerPlatform: Send + Sync { /// Open the viewfinder on behalf of `product` and wait for the user. diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index d7077a2ce1..5f66e1569c 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -273,7 +273,7 @@ where /// withdrawn call stops waiting, so an answer given after the withdrawal /// authorizes nothing. The error is the one `remote_authority_call` answers, /// so each caller maps it into its method's own domain error. -pub(crate) async fn until_cancelled( +async fn until_cancelled( cx: &CallContext, wait: impl Future, ) -> Result { @@ -1398,7 +1398,11 @@ impl Contacts for ProductRuntimeHost { let generation = self.services.contact_handles.generation(); let outcome = match until_cancelled(cx, platform.pick_contact(&self.product)) .await - .map_err(|_cancelled| CallError::Cancelled)? + .map_err(|cancelled| { + CallError::Domain(wrap(v01::HostContactsPickError::Unknown { + reason: cancelled.to_string(), + })) + })? .map_err(unknown)? { crate::platform::HostContactPick::Picked { account } => { diff --git a/rust/crates/truapi/src/runtime/capabilities/scanner.rs b/rust/crates/truapi/src/runtime/capabilities/scanner.rs index 991bac8dc8..a1e090a39f 100644 --- a/rust/crates/truapi/src/runtime/capabilities/scanner.rs +++ b/rust/crates/truapi/src/runtime/capabilities/scanner.rs @@ -40,7 +40,11 @@ impl Scanner for ProductRuntimeHost { let answer = until_cancelled(cx, platform.scan_code(&self.product, &request)) .await - .map_err(|_cancelled| CallError::Cancelled)? + .map_err(|cancelled| { + domain(latest::HostScannerScanError::Unknown { + reason: cancelled.to_string(), + }) + })? .map_err(|error| { domain(latest::HostScannerScanError::Unknown { reason: error.reason, diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 6534928115..730329c2a6 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -5,7 +5,7 @@ //! controls live on the concrete role objects. use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 3ba12ee5e0..d95531acb8 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7928,10 +7928,12 @@ fn a_second_scan_waits_its_turn_and_a_cancel_frees_the_viewfinder() { ); cancel.cancel(); - assert_eq!( + // The handler reports its own error; the dispatcher answers `Cancelled` + // only when the product sent a Cancel frame. + assert!(matches!( unwrap_scan(futures::executor::block_on(first)), - Err(CallError::Cancelled) - ); + Err(CallError::Domain(v01::HostScannerScanError::Unknown { .. })) + )); assert!(scanner.abandoned.load(Ordering::SeqCst)); let next_cx = CallContext::default(); @@ -7989,6 +7991,13 @@ fn cancelling_a_contact_pick_stops_waiting_on_the_picker() { cancel.cancel(); - assert_eq!(futures::executor::block_on(pick), Err(CallError::Cancelled)); + // The handler reports its own error; the dispatcher answers `Cancelled` + // only when the product sent a Cancel frame. + assert!(matches!( + futures::executor::block_on(pick), + Err(CallError::Domain(HostContactsPickError::V1( + v01::HostContactsPickError::Unknown { .. } + ))) + )); assert!(abandoned.load(Ordering::SeqCst)); } From 828e53496416efa390be5d66664e68ec168bdd5f Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 13:25:49 -0300 Subject: [PATCH 15/29] fix(truapi): tighten scanner request checks after the second review Review of #1307: - A format may be named only once, so the list the filter checks on every camera frame stays as short as the formats. - Hints may not carry invisible characters (zero-width, word joiners, byte order mark, annotation marks). - One unknown() helper builds the handler's catch-all error. - The JS mock keeps its scan answer only when it serves a scanner, so it holds no default that can never be read. - setScanAnswer is no longer listed as part of host-api-test-sdk's surface, which has no scanner. - The README line for make e2e-scanner-cli names both host roles. --- README.md | 2 +- docs/rfcs/host-scanner.md | 7 ++-- .../src/testing/test-host-surface.test.ts | 1 - .../truapi-host/src/web/create-mock-host.ts | 11 ++--- rust/crates/truapi/src/host_logic/scanner.rs | 40 ++++++++++++++++--- .../src/runtime/capabilities/scanner.rs | 24 +++++------ rust/crates/truapi/src/v01/scanner.rs | 6 +-- 7 files changed, 60 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f149be1e89..c2811e3049 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ make e2e-signing-cli # same direct signing-host phase make e2e-pairing-cli # same paired pairing-host phase make e2e-chat-cli # chat content screening against a chat signing-host make e2e-pocket-cli # Pocket protocol check against a Pocket signing-host -make e2e-scanner-cli # scanner checks against a signing-host that scans a fixed code +make e2e-scanner-cli # scanner checks against both CLI host roles, scanning a fixed code ``` The Pocket phase runs its product as a Worker execution, the only execution diff --git a/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md index 7b10307fd7..712beef643 100644 --- a/docs/rfcs/host-scanner.md +++ b/docs/rfcs/host-scanner.md @@ -57,7 +57,7 @@ pub enum CodeFormat { } pub struct HostScannerScanRequest { - /// Formats the product accepts. At least one. + /// Formats the product accepts. At least one, each named once. pub formats: Vec, /// Start the text must have, ignoring ASCII letter case. At most 256 bytes of UTF-8. pub prefix: Option, @@ -79,7 +79,7 @@ pub enum HostScannerScanError { CameraUnavailable, /// Another scan is already open. Busy, - /// No formats, or a prefix or hint that breaks its limit. + /// No formats, a format named twice, or a prefix or hint that breaks its limit. InvalidRequest { reason: String }, Unknown { reason: String }, } @@ -109,7 +109,8 @@ One call scans one code. A product that wants several calls again. Cancelling th **The host owns the viewfinder.** The host writes the title itself and names the product by its id, for example "Scan for greenmarket.dot". The product cannot draw over the viewfinder or change the title. The hint is shown below the title as the product's own words, so the user can tell it apart from host text. To keep it to one plain line, a hint -may not contain control characters, line or paragraph separators, or characters that change text direction. +may not contain control characters, line or paragraph separators, invisible characters, or characters that change text +direction. **The host does not act on what it scanned.** The host's own scanner treats some codes as links. A link to another product opens it, a pairing link starts sign-in, and a payment link opens a payment screen. A product's scan skips all diff --git a/js/packages/truapi-host/src/testing/test-host-surface.test.ts b/js/packages/truapi-host/src/testing/test-host-surface.test.ts index b91de8525e..af0e289931 100644 --- a/js/packages/truapi-host/src/testing/test-host-surface.test.ts +++ b/js/packages/truapi-host/src/testing/test-host-surface.test.ts @@ -50,7 +50,6 @@ const TEST_HOST_API = [ "setPaymentBalance", "setPaymentTopUpBehavior", "setPermissionBehavior", - "setScanAnswer", "setTheme", "simulateDisconnect", "simulatePaymentStatus", diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index c22724935a..b6aaa45296 100644 --- a/js/packages/truapi-host/src/web/create-mock-host.ts +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -868,7 +868,8 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { }, } = config; - let scanAnswer: HostScan = scanner ?? { tag: "Dismissed" }; + // Present only when the mock serves a scanner, so `setScanAnswer` can tell. + const scan = scanner === undefined ? undefined : { answer: scanner }; const storage = new Map(); const preimages = new Map(); const navigations: string[] = []; @@ -1180,12 +1181,12 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { async cancelGameReminder() {}, }, - ...(scanner === undefined + ...(scan === undefined ? {} : { scanner: { async scanCode() { - return scanAnswer; + return scan.answer; }, }, }), @@ -1519,12 +1520,12 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { }, getTheme: () => currentTheme, setScanAnswer: (answer) => { - if (scanner === undefined) { + if (scan === undefined) { throw new Error( "this mock serves no scanner: create it with a `scanner` answer", ); } - scanAnswer = answer; + scan.answer = answer; }, setTheme: (variant) => { currentTheme = variant; diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index 7b38d1519d..7cffef6738 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -19,6 +19,14 @@ pub fn validate_request(request: &HostScannerScanRequest) -> Result<(), String> if request.formats.is_empty() { return Err("formats is empty".into()); } + if request + .formats + .iter() + .enumerate() + .any(|(index, format)| request.formats[..index].contains(format)) + { + return Err("formats names a format twice".into()); + } if let Some(prefix) = &request.prefix && prefix.len() > MAX_PREFIX_BYTES { @@ -35,8 +43,8 @@ pub fn validate_request(request: &HostScannerScanRequest) -> Result<(), String> Ok(()) } -/// Characters that would let a hint break out of its line or flip its -/// direction under the host's title. +/// Characters that would let a hint break out of its line, flip its +/// direction, or hide text under the host's title. fn breaks_the_line(character: char) -> bool { character.is_control() || matches!( @@ -44,10 +52,11 @@ fn breaks_the_line(character: char) -> bool { '\u{2028}' | '\u{2029}' | '\u{061C}' - | '\u{200E}' - | '\u{200F}' + | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' - | '\u{2066}'..='\u{2069}' + | '\u{2060}'..='\u{2069}' + | '\u{FEFF}' + | '\u{FFF9}'..='\u{FFFB}' ) } @@ -145,6 +154,23 @@ mod tests { assert!(validate_request(&qr(None, None)).is_ok()); } + #[test] + fn a_format_may_be_named_only_once() { + // The filter checks the list on every camera frame, so it stays as + // short as the formats themselves. + assert!( + validate_request(&request(vec![CodeFormat::Qr, CodeFormat::Qr], None, None)).is_err() + ); + assert!( + validate_request(&request( + vec![CodeFormat::Qr, CodeFormat::Ean13], + None, + None + )) + .is_ok() + ); + } + #[test] fn the_hint_is_counted_in_unicode_scalars() { // TypeScript products count with `[...hint].length`, which agrees with this. @@ -165,6 +191,10 @@ mod tests { "\u{2066}isolate", "\u{200F}mark", "\u{061C}arabic letter mark", + "zero\u{200B}width", + "word\u{2060}joiner", + "\u{FEFF}byte order mark", + "\u{FFF9}annotation", ] { assert!( validate_request(&qr(None, Some(refused))).is_err(), diff --git a/rust/crates/truapi/src/runtime/capabilities/scanner.rs b/rust/crates/truapi/src/runtime/capabilities/scanner.rs index a1e090a39f..defa3ad0a8 100644 --- a/rust/crates/truapi/src/runtime/capabilities/scanner.rs +++ b/rust/crates/truapi/src/runtime/capabilities/scanner.rs @@ -16,6 +16,12 @@ fn domain(error: latest::HostScannerScanError) -> CallError CallError { + domain(latest::HostScannerScanError::Unknown { + reason: reason.to_string(), + }) +} + #[truapi::async_trait] impl Scanner for ProductRuntimeHost { /// Checked in this order so a host without a scanner, or a request the @@ -40,25 +46,17 @@ impl Scanner for ProductRuntimeHost { let answer = until_cancelled(cx, platform.scan_code(&self.product, &request)) .await - .map_err(|cancelled| { - domain(latest::HostScannerScanError::Unknown { - reason: cancelled.to_string(), - }) - })? - .map_err(|error| { - domain(latest::HostScannerScanError::Unknown { - reason: error.reason, - }) - })?; + .map_err(unknown)? + .map_err(|error| unknown(error.reason))?; let outcome = match answer { HostScan::Scanned { text, format } if accepts(&request, format, &text) => { latest::ScanOutcome::Scanned { text, format } } HostScan::Scanned { .. } => { - return Err(domain(latest::HostScannerScanError::Unknown { - reason: "the host returned a code the request does not accept".into(), - })); + return Err(unknown( + "the host returned a code the request does not accept", + )); } HostScan::Dismissed => latest::ScanOutcome::Dismissed, HostScan::CameraUnavailable => { diff --git a/rust/crates/truapi/src/v01/scanner.rs b/rust/crates/truapi/src/v01/scanner.rs index 9a9ee47671..4c7aeb8216 100644 --- a/rust/crates/truapi/src/v01/scanner.rs +++ b/rust/crates/truapi/src/v01/scanner.rs @@ -44,15 +44,15 @@ pub enum CodeFormat { derive(uniffi::Record) )] pub struct HostScannerScanRequest { - /// Formats the product accepts. At least one. + /// Formats the product accepts. At least one, each named once. pub formats: Vec, /// Start the text must have, compared ignoring ASCII letter case, since QR /// codes often carry URLs in capitals. At most 256 bytes of UTF-8. pub prefix: Option, /// What to point the camera at, shown as the product's words. At most 80 /// Unicode scalar values (`[...hint].length` in TypeScript). No control - /// characters, line or paragraph separators, or bidirectional formatting - /// characters. + /// characters, line or paragraph separators, invisible characters, or + /// bidirectional formatting characters. pub hint: Option, } From 7d9651b643d3c154ac167ad0a7537ea0ebfbbbb4 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 13:55:31 -0300 Subject: [PATCH 16/29] docs(rfc): keep pairing requests from products and scan only on screen A code the core would accept as a pairing request is never delivered, whatever the request's prefix, since it lets whoever answers it pair with the device that showed it. The prefix stays optional: barcodes have none, and a required one would not stop a product asking for the pairing link. Settles the open question: an App or Widget scans only while it is on screen, and a Worker only within 5 seconds of a user tap; anything else is answered with the new NotVisible error. --- docs/rfcs/host-scanner.md | 28 ++++++++++++++----- .../truapi-codegen/tests/golden/wire_table.rs | 2 +- rust/crates/truapi/src/v01/scanner.rs | 3 ++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md index 7b10307fd7..661901ef38 100644 --- a/docs/rfcs/host-scanner.md +++ b/docs/rfcs/host-scanner.md @@ -79,6 +79,8 @@ pub enum HostScannerScanError { CameraUnavailable, /// Another scan is already open. Busy, + /// The calling execution is not on screen, and is not a Worker handling a tap. + NotVisible, /// No formats, or a prefix or hint that breaks its limit. InvalidRequest { reason: String }, Unknown { reason: String }, @@ -100,6 +102,7 @@ What each answer means for the product: | `Dismissed` | Can offer the scan again. | | `CameraUnavailable` | Stops offering it until the user changes a setting. | | `Busy` | Waits for the other scan to end. | +| `NotVisible` | Offers the scan from its own screen, or from a tap on its card. | | `InvalidRequest` | Fixes the call. Nothing was shown. | | `Unsupported` | Uses its own camera code. This host will never have a scanner. | @@ -116,6 +119,19 @@ product opens it, a pairing link starts sign-in, and a payment link opens a paym of that and returns the text. If the product wants to follow a scanned link, it calls `navigate_to`, which keeps its own rules. +**A pairing request never reaches a product.** A pairing link lets whoever answers it first pair with the device that +showed it, so it is a credential. The core refuses any code it would itself accept as a pairing request, whatever the +request's formats and prefix, including the bare handshake without the `pair` link around it. In the viewfinder it is +treated like any code that is not for this product. The prefix stays optional, because a barcode such as an EAN-13 +grocery code has no prefix to give, and a required prefix would not help anyway: a product could pass +`polkadotapp://pair?` as its prefix. + +**Only for what the user is looking at.** The viewfinder opens over whatever is on screen, so the call has to come +from there. An App or Widget may scan only while it is the screen the user sees; the host checks that. A Worker has no +screen of its own, so it may scan only within 5 seconds of the core delivering it a tap from the user, on its card face +or in a chat message. Anything else is answered `NotVisible` and no viewfinder opens. A product therefore cannot open +the viewfinder from the background, or open it again each time the user closes it. + **No permission.** The product never touches the camera, and the user pointing the viewfinder at a code is the consent. The OS still asks the host application for camera access the first time. The `Camera` permission and `getUserMedia` do not change. A product that needs the camera for anything else, or wants its own scanning screen, keeps using them. @@ -138,6 +154,10 @@ test supplies, so a product can test its scan flow without a camera. - A prefix is the only content filter. A product that needs finer checks reads the text itself and calls again. - The prefix ignores ASCII letter case, because QR codes often store URLs in capitals. A product that cares about case checks the text itself. +- A product that gives no prefix can receive any code the user points the viewfinder at, apart from a pairing request. + The title names the product, so pointing the camera is the user's choice, as with the contact picker. +- A Worker's 5-second window follows browsers' transient user activation. A Worker that needs longer between the tap + and the scan is doing work the user is not waiting on, and should not scan. - Raw bytes are not returned. They can be added later as an optional field without breaking existing callers. - Web hosts have no scanner yet, so a product keeps its own `getUserMedia` fallback there. A bundled decoder can come later without changing this API. @@ -149,10 +169,4 @@ test supplies, so a product can test its scan flow without a camera. - Accepting or rejecting each code live. It needs a two-way exchange that no product needs yet. - The host following scanned links. The user would leave the product mid-scan. - A `Camera` grant on `scan`. The product never receives camera data. - -## Open questions - -Any execution can call `scan`, including a Worker behind a Pocket card the user is not looking at. The title always -names the product and the user can close the viewfinder, which matches the contact picker today. But a product could -open the viewfinder again every time the user closes it, and while it is open other products get `Busy`. Should the -host open the viewfinder only for a product that is on screen, or only right after the user taps something in it? + - A required prefix. Barcodes have no prefix to give, and a product could pass a pairing link's start as its prefix. diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 1789be6648..4eb30df706 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 = "9ea91bca72d5e658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "c130fa60ef495768"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { diff --git a/rust/crates/truapi/src/v01/scanner.rs b/rust/crates/truapi/src/v01/scanner.rs index f01132c686..8b04e56de1 100644 --- a/rust/crates/truapi/src/v01/scanner.rs +++ b/rust/crates/truapi/src/v01/scanner.rs @@ -83,6 +83,9 @@ pub enum HostScannerScanError { CameraUnavailable, /// Another scan is open. Busy, + /// The calling execution is not on screen, and is not a Worker handling a + /// tap from the user, so no viewfinder was opened. + NotVisible, /// The request breaks a limit, so no viewfinder was shown. InvalidRequest { /// Which limit. From 69419dbc3d17e6a90d56e43bcfdc73db187dc75f Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 13:57:56 -0300 Subject: [PATCH 17/29] docs(rfc): only a tap on host-drawn UI lets a Worker scan --- docs/rfcs/host-scanner.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md index 661901ef38..6a2949ea91 100644 --- a/docs/rfcs/host-scanner.md +++ b/docs/rfcs/host-scanner.md @@ -128,8 +128,8 @@ grocery code has no prefix to give, and a required prefix would not help anyway: **Only for what the user is looking at.** The viewfinder opens over whatever is on screen, so the call has to come from there. An App or Widget may scan only while it is the screen the user sees; the host checks that. A Worker has no -screen of its own, so it may scan only within 5 seconds of the core delivering it a tap from the user, on its card face -or in a chat message. Anything else is answered `NotVisible` and no viewfinder opens. A product therefore cannot open +screen of its own, so it may scan only within 5 seconds of the user tapping something the host drew for it, such as +its card face. Chat messages and actions do not count, because they can come from other people. Anything else is answered `NotVisible` and no viewfinder opens. A product therefore cannot open the viewfinder from the background, or open it again each time the user closes it. **No permission.** The product never touches the camera, and the user pointing the viewfinder at a code is the consent. From 9936044d5757510edae3c7faf3ee17e5d49a3be1 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 13:58:01 -0300 Subject: [PATCH 18/29] docs(rfc): rewrap the visibility rule --- docs/rfcs/host-scanner.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md index 6a2949ea91..f7e4c03ef6 100644 --- a/docs/rfcs/host-scanner.md +++ b/docs/rfcs/host-scanner.md @@ -126,11 +126,12 @@ treated like any code that is not for this product. The prefix stays optional, b grocery code has no prefix to give, and a required prefix would not help anyway: a product could pass `polkadotapp://pair?` as its prefix. -**Only for what the user is looking at.** The viewfinder opens over whatever is on screen, so the call has to come -from there. An App or Widget may scan only while it is the screen the user sees; the host checks that. A Worker has no -screen of its own, so it may scan only within 5 seconds of the user tapping something the host drew for it, such as -its card face. Chat messages and actions do not count, because they can come from other people. Anything else is answered `NotVisible` and no viewfinder opens. A product therefore cannot open -the viewfinder from the background, or open it again each time the user closes it. +**Only for what the user is looking at.** The viewfinder opens over whatever is on screen, so the call has to come from +there. An App or Widget may scan only while it is the screen the user sees; the host checks that. A Worker has no screen +of its own, so it may scan only within 5 seconds of the user tapping something the host drew for it, such as its card +face. Chat messages and actions do not count, because they can come from other people. Anything else is answered +`NotVisible` and no viewfinder opens. A product therefore cannot open the viewfinder from the background, or open it +again each time the user closes it. **No permission.** The product never touches the camera, and the user pointing the viewfinder at a code is the consent. The OS still asks the host application for camera access the first time. The `Camera` permission and `getUserMedia` do From fd364b63fa2d877d4fa2a0a709a9226a5295e38a Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 14:08:24 -0300 Subject: [PATCH 19/29] fix(truapi): never hand a product a pairing request it scanned Whoever answers a pairing link pairs with the device that showed it, so the scan filter and the core's re-check refuse any code the core would accept as a pairing request, with or without the pair link around the handshake and whatever the request's prefix. --- rust/crates/truapi/src/host_logic/scanner.rs | 35 +++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index 7cffef6738..3b475d2bc5 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -61,14 +61,24 @@ fn breaks_the_line(character: char) -> bool { } /// Whether a code of `format` reading `text` answers `request`. +/// +/// A pairing request never does, whatever the request asks for: whoever +/// answers one pairs with the device that showed it. pub fn accepts(request: &HostScannerScanRequest, format: CodeFormat, text: &str) -> bool { - request.formats.contains(&format) + !is_pairing_request(text) + && request.formats.contains(&format) && request.prefix.as_deref().is_none_or(|prefix| { text.get(..prefix.len()) .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) }) } +/// Whether the core would accept `text` as a pairing request, with or without +/// the `pair` link around the handshake. +fn is_pairing_request(text: &str) -> bool { + crate::host_logic::sso::pairing::decode_pairing_deeplink(text).is_ok() +} + /// What a host does with one code the camera read. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] @@ -281,6 +291,29 @@ mod tests { } } + #[test] + fn a_pairing_request_never_reaches_a_product() { + // Whoever answers a pairing link pairs with the device that showed it, + // so no request may receive one, not even one asking for its prefix. + let (config, _) = crate::test_support::runtime_config("greenmarket.dot"); + let link = crate::host_logic::sso::pairing::build_pairing_deeplink( + "polkadotapp", + [1; 32], + [2; 32], + &config, + ); + let other_scheme = link.replacen("polkadotapp", "otherwallet", 1); + let bare_handshake = link.split_once("?handshake=").unwrap().1.to_owned(); + for text in [&link, &other_scheme, &bare_handshake] { + assert!(!accepts(&qr(None, None), CodeFormat::Qr, text), "{text}"); + assert!(!accepts(&qr(Some("polkadotapp://pair?"), None), CodeFormat::Qr, text)); + } + assert_eq!( + ScanFilter::new(qr(None, None)).observe(CodeFormat::Qr, link), + ScanVerdict::NotForThisProduct + ); + } + #[test] fn the_filter_still_accepts_after_refusing() { let filter = ScanFilter::new(receipts()); From 72488ffe323bd4f5c574824d4b21c19659c4ce2e Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 14:08:24 -0300 Subject: [PATCH 20/29] feat(truapi): open the scanner only for what the user is looking at A Worker may scan only within 5 seconds of a renderer action the host delivered to it; otherwise the core answers NotVisible without asking the host. For an App or Widget the host answers HostScan::NotVisible when it is not on screen, so native hosts now receive the execution kind. --- .../kotlin/io/parity/truapi/TrUAPIHost.kt | 18 ++++- .../Sources/TrUAPIHost/TrUAPIHost.swift | 18 ++++- .../tests/golden/host-callbacks.ts | 14 +++- rust/crates/truapi/RUNTIME.md | 16 ++-- rust/crates/truapi/src/native/callbacks.rs | 8 +- rust/crates/truapi/src/native/platform.rs | 6 +- rust/crates/truapi/src/platform.rs | 9 +++ rust/crates/truapi/src/runtime.rs | 29 +++++++- .../src/runtime/capabilities/scanner.rs | 8 +- rust/crates/truapi/src/runtime/tests.rs | 73 ++++++++++++++++++- 10 files changed, 181 insertions(+), 18 deletions(-) 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 5afa2a6056..f381255fd7 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 @@ -92,6 +92,7 @@ import uniffi.truapi.NativeContactsCallbacks import uniffi.truapi.HostScan import uniffi.truapi.HostScannerScanRequest import uniffi.truapi.NativeScannerCallbacks +import uniffi.truapi.ProductExecutionKind /** Package metadata. */ object TrUAPIHost { @@ -681,15 +682,26 @@ private class ContactsCallbackAdapter(private val bridge: ContactsHostBridge) : * product's words. Hand every code the camera reads to a `ScanFilter` built from * [request] and act on its verdict. Never follow a scanned link. Close the * viewfinder when the coroutine is cancelled. + * + * An App or Widget scans only while it is the screen the user sees: answer + * `HostScan.NotVisible` otherwise, without opening anything. A Worker's request + * has already passed the core's tap check, so open it over whatever is on screen. */ interface ScannerHostBridge { @Throws(HostRejection::class) - suspend fun scanCode(productId: String, request: HostScannerScanRequest): HostScan + suspend fun scanCode( + productId: String, + executionKind: ProductExecutionKind, + request: HostScannerScanRequest, + ): HostScan } private class ScannerCallbackAdapter(private val bridge: ScannerHostBridge) : NativeScannerCallbacks { - override suspend fun scanCode(productId: String, request: HostScannerScanRequest): HostScan = - withHostRejection { bridge.scanCode(productId, request) } + override suspend fun scanCode( + productId: String, + executionKind: ProductExecutionKind, + request: HostScannerScanRequest, + ): HostScan = withHostRejection { bridge.scanCode(productId, executionKind, request) } } private class PocketCallbackAdapter(private val bridge: PocketHostBridge) : NativePocketCallbacks { diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 8733129b72..8f479f1410 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -311,8 +311,16 @@ public protocol ContactsHostBridge: AnyObject, Sendable { /// the product's words. Hand every code the camera reads to a `ScanFilter` /// built from `request` and act on its verdict. Never follow a scanned link. /// Close the viewfinder when the task is cancelled. +/// +/// An App or Widget scans only while it is the screen the user sees: answer +/// `.notVisible` otherwise, without opening anything. A Worker's request has +/// already passed the core's tap check, so open it over whatever is on screen. public protocol ScannerHostBridge: AnyObject, Sendable { - func scanCode(productId: String, request: HostScannerScanRequest) async throws -> HostScan + func scanCode( + productId: String, + executionKind: ProductExecutionKind, + request: HostScannerScanRequest + ) async throws -> HostScan } public extension HostBridge { @@ -476,9 +484,13 @@ private final class ScannerCallbackAdapter: NativeScannerCallbacks, @unchecked S self.bridge = bridge } - func scanCode(productId: String, request: HostScannerScanRequest) async throws -> HostScan { + func scanCode( + productId: String, + executionKind: ProductExecutionKind, + request: HostScannerScanRequest + ) async throws -> HostScan { try await withHostRejection { - try await bridge.scanCode(productId: productId, request: request) + try await bridge.scanCode(productId: productId, executionKind: executionKind, request: request) } } } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4f8693422c..aa5d1f6bb3 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -370,7 +370,13 @@ export type HostScan = * The device has no camera, or the user refused the host application one. * The host has already told the user how to turn it on. */ - | { tag: "CameraUnavailable"; value?: undefined }; + | { tag: "CameraUnavailable"; value?: undefined } + /** + * The requesting App or Widget is not the screen the user sees, so the + * host opened nothing. A Worker's request was already checked by the core + * and is never answered this way. + */ + | { tag: "NotVisible"; value?: undefined }; /** * Review shown before a product learns the user's primary identity. @@ -900,6 +906,7 @@ export const HostScan: S.Codec = S.lazy( }>, Dismissed: S._void, CameraUnavailable: S._void, + NotVisible: S._void, }), ); @@ -1714,6 +1721,11 @@ export interface ProductStorage { * before that close has finished, so close any viewfinder still open before * opening another. A JS host is not told about a cancel, because a promise * cannot be withdrawn. + * + * An App or Widget scans only while it is the screen the user sees: answer + * `HostScan::NotVisible` otherwise, without opening anything. A Worker's + * request has already passed the core's tap check, so open it over whatever is + * on screen. */ export interface ScannerPlatform { /** diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 02b35cbf8f..956b00ce3e 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -416,12 +416,16 @@ AutoSigning without approval. Legacy-account signing still asks the user. - `ScannerPlatform`: open the host's QR and barcode viewfinder for a product and report how it ended. The core checks the request first, answering `Unsupported` without a scanner, `InvalidRequest` for a request over its - limits, and `Busy` while another scan is open, since the device has one - viewfinder. It then calls the host and checks the code again, so a code the - request does not accept reaches the product as `Unknown`. A cancelled call - drops the host's future, which is the host's signal to close the viewfinder. - The host filters codes with the core's `ScanFilter` and never follows a - scanned link. + limits, `NotVisible` for a Worker the user has not tapped in the last 5 + seconds, and `Busy` while another scan is open, since the device has one + viewfinder. A tap is a renderer action the host delivered to that + connection. The core then calls the host, which answers `NotVisible` itself + for an App or Widget that is not on screen, and checks the code again, so a + code the request does not accept reaches the product as `Unknown`. A code + the core would accept as a pairing request is never accepted, whatever the + request asks for. A cancelled call drops the host's future, which is the + host's signal to close the viewfinder. The host filters codes with the + core's `ScanFilter` and never follows a scanned link. `Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, `ContactsPlatform`, `PermissionStatusHost`, diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index f323a2a6f4..99a8a468fa 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -335,13 +335,19 @@ pub trait NativeContactsCallbacks: Send + Sync { /// [`crate::host_logic::scanner::ScanFilter`] built from `request`, and never /// follow a scanned link. Dropping the returned future cancels the foreign /// task; close the viewfinder when that happens. +/// +/// An App or Widget scans only while it is the screen the user sees: answer +/// `NotVisible` otherwise, without opening anything. A Worker's request has +/// already passed the core's tap check, so open it over whatever is on screen. #[uniffi::export(rust, foreign)] #[async_trait::async_trait] pub trait NativeScannerCallbacks: Send + Sync { - /// Open the viewfinder on behalf of `product_id` and report how it ended. + /// Open the viewfinder on behalf of `product_id`'s execution of + /// `execution_kind` and report how it ended. async fn scan_code( &self, product_id: String, + execution_kind: crate::platform::ProductExecutionKind, request: truapi::latest::HostScannerScanRequest, ) -> Result; } diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 79018f419a..ebd1eceec8 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -70,7 +70,11 @@ impl crate::platform::ScannerPlatform for ScannerCallbackPlatform { request: &truapi::latest::HostScannerScanRequest, ) -> Result { self.scanner - .scan_code(product.product_id.clone(), request.clone()) + .scan_code( + product.product_id.clone(), + product.execution_kind, + request.clone(), + ) .await .map_err(|error| v01::GenericError { reason: error.to_string(), diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index ce8ead699d..c0e29bba41 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3509,6 +3509,10 @@ pub enum HostScan { /// The device has no camera, or the user refused the host application one. /// The host has already told the user how to turn it on. CameraUnavailable, + /// The requesting App or Widget is not the screen the user sees, so the + /// host opened nothing. A Worker's request was already checked by the core + /// and is never answered this way. + NotVisible, } /// Host-owned viewfinder for QR codes and barcodes. @@ -3523,6 +3527,11 @@ pub enum HostScan { /// before that close has finished, so close any viewfinder still open before /// opening another. A JS host is not told about a cancel, because a promise /// cannot be withdrawn. +/// +/// An App or Widget scans only while it is the screen the user sees: answer +/// [`HostScan::NotVisible`] otherwise, without opening anything. A Worker's +/// request has already passed the core's tap check, so open it over whatever is +/// on screen. #[async_trait] pub trait ScannerPlatform: Send + Sync { /// Open the viewfinder on behalf of `product` and wait for the user. diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 5f66e1569c..6977dec618 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -42,6 +42,7 @@ mod statement_store_rpc; mod vrf; use core::future::Future; +use core::sync::atomic::{AtomicU64, Ordering}; use core::time::Duration; use std::collections::HashSet; use std::sync::{Arc, Mutex}; @@ -321,8 +322,16 @@ pub struct ProductRuntimeHost { /// operations is the host's call, made in `begin_operation`, since the /// host is what the operations keep running. open_operations: Mutex>, + /// When the user last tapped UI the host drew for this connection, in unix + /// seconds, or zero before the first tap. A Worker may scan only shortly + /// after one, because it has no screen the user could be looking at. + last_user_tap_secs: AtomicU64, } +/// How long after a tap a Worker may still open the scanner, as browsers bound +/// transient user activation. +pub(crate) const USER_TAP_WINDOW_SECS: u64 = 5; + /// A connection that goes away without ending its operations still owes the /// ledger their references, so the host is told to stop rather than keeping a /// worker alive for a product that is gone. @@ -356,9 +365,24 @@ impl ProductRuntimeHost { pocket_platform: adapters.pocket_platform, game_platform: adapters.game_platform, open_operations: Mutex::new(HashSet::new()), + last_user_tap_secs: AtomicU64::new(0), } } + /// Record that the user tapped UI the host drew for this connection. + pub(crate) fn note_user_tap_at(&self, unix_secs: u64) { + self.last_user_tap_secs.store(unix_secs, Ordering::Release); + } + + /// Whether the user tapped UI the host drew for this connection within the + /// last [`USER_TAP_WINDOW_SECS`]. + pub(crate) fn recently_tapped(&self) -> bool { + let tapped_at = self.last_user_tap_secs.load(Ordering::Acquire); + tapped_at != 0 + && crate::unix_time::current_unix_secs().saturating_sub(tapped_at) + <= USER_TAP_WINDOW_SECS + } + /// Role-neutral services shared with the owning host runtime. pub fn services(&self) -> &Arc { &self.services @@ -486,6 +510,7 @@ impl ProductRuntimeHost { pocket_platform: None, game_platform: None, open_operations: Mutex::new(HashSet::new()), + last_user_tap_secs: AtomicU64::new(0), }; (host, pairing_host) } @@ -1247,7 +1272,9 @@ impl ProductRuntimeHost { item: HostRendererActionSubscribeItem, ) -> Result<(), crate::host_core::ProductRuntimeError> { self.renderer_access()?; - self.renderer.publish(item) + self.renderer.publish(item)?; + self.note_user_tap_at(crate::unix_time::current_unix_secs()); + Ok(()) } /// Pocket access policy for this connection: the collection is reachable diff --git a/rust/crates/truapi/src/runtime/capabilities/scanner.rs b/rust/crates/truapi/src/runtime/capabilities/scanner.rs index defa3ad0a8..56930648f6 100644 --- a/rust/crates/truapi/src/runtime/capabilities/scanner.rs +++ b/rust/crates/truapi/src/runtime/capabilities/scanner.rs @@ -9,7 +9,7 @@ use truapi::versioned::scanner::{ use truapi::{CallContext, CallError}; use crate::host_logic::scanner::{accepts, validate_request}; -use crate::platform::HostScan; +use crate::platform::{HostScan, ProductExecutionKind}; use crate::runtime::{ProductRuntimeHost, until_cancelled}; fn domain(error: latest::HostScannerScanError) -> CallError { @@ -39,6 +39,9 @@ impl Scanner for ProductRuntimeHost { .ok_or(CallError::Unsupported)?; validate_request(&request) .map_err(|reason| domain(latest::HostScannerScanError::InvalidRequest { reason }))?; + if self.execution_kind() == ProductExecutionKind::Worker && !self.recently_tapped() { + return Err(domain(latest::HostScannerScanError::NotVisible)); + } let _claim = self .services .claim_scan() @@ -62,6 +65,9 @@ impl Scanner for ProductRuntimeHost { HostScan::CameraUnavailable => { return Err(domain(latest::HostScannerScanError::CameraUnavailable)); } + HostScan::NotVisible => { + return Err(domain(latest::HostScannerScanError::NotVisible)); + } }; Ok(HostScannerScanResponse::V1( latest::HostScannerScanResponse { outcome }, diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index d95531acb8..c8afd17402 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7752,7 +7752,16 @@ impl crate::platform::ScannerPlatform for StubScannerPlatform { /// A runtime for `greenmarket.dot` with no session, and `scanner` installed /// when given. fn scanner_host(scanner: Option>) -> ProductRuntimeHost { - let (host_config, product) = runtime_config("greenmarket.dot"); + scanner_host_for(crate::platform::ProductExecutionKind::App, scanner) +} + +/// [`scanner_host`] for an execution of `kind`. +fn scanner_host_for( + kind: crate::platform::ProductExecutionKind, + scanner: Option>, +) -> ProductRuntimeHost { + let (host_config, mut product) = runtime_config("greenmarket.dot"); + product.execution_kind = kind; let services = RuntimeServices::with_chat_platform( stub_platform() as Arc, host_config.host.host_info.clone(), @@ -7808,6 +7817,68 @@ fn scan( scan_in(host, &CallContext::default()) } +/// The user tapping a button on the product's card face. +fn card_tap() -> truapi::versioned::renderer::HostRendererActionSubscribeItem { + truapi::versioned::renderer::HostRendererActionSubscribeItem::V1( + v01::HostRendererActionSubscribeItem { + context: v01::RenderContext::PocketCard { + card_id: "receipts".into(), + }, + action_id: "scan".into(), + payload: vec![], + }, + ) +} + +#[test] +fn a_worker_scans_only_right_after_the_user_taps_its_card() { + // A Worker has no screen of its own. Without a tap, the viewfinder would + // open over whatever the user is doing. + let scanner = StubScannerPlatform::answering(crate::platform::HostScan::Dismissed); + let host = scanner_host_for( + crate::platform::ProductExecutionKind::Worker, + Some(scanner.clone()), + ); + assert_eq!( + scan(&host), + Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) + ); + assert!(scanner.asked().is_empty()); + + host.publish_renderer_action(card_tap()).unwrap(); + assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); + assert_eq!(scanner.asked().len(), 1); +} + +#[test] +fn a_tap_older_than_the_window_does_not_let_a_worker_scan() { + let scanner = StubScannerPlatform::answering(crate::platform::HostScan::Dismissed); + let host = scanner_host_for( + crate::platform::ProductExecutionKind::Worker, + Some(scanner.clone()), + ); + host.note_user_tap_at( + crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1, + ); + assert_eq!( + scan(&host), + Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) + ); + assert!(scanner.asked().is_empty()); +} + +#[test] +fn a_page_the_host_says_is_off_screen_is_not_visible() { + // Only the host knows which page the user is looking at. + let host = scanner_host(Some(StubScannerPlatform::answering( + crate::platform::HostScan::NotVisible, + ))); + assert_eq!( + scan(&host), + Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) + ); +} + fn scanned(text: &str, format: v01::CodeFormat) -> crate::platform::HostScan { crate::platform::HostScan::Scanned { text: text.into(), From e277cff2bbb88afc5de8f66351520307e409f6dc Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 15:24:14 -0300 Subject: [PATCH 21/29] refactor: say the scanner rules once and trim the scanner tests The host's viewfinder rules now live only on ScannerPlatform. The native trait, the Swift and Kotlin bridges, RUNTIME.md and the truapi-host README point to it instead of repeating it, and the setter docs are one line. The tests keep every case but drop the scaffolding: case tables instead of one assert per block, one scanner stub constructor, and the CLI scanner cases inline in their battery script. --- .../kotlin/io/parity/truapi/TrUAPIHost.kt | 19 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 21 +- js/packages/truapi-host/README.md | 12 +- .../truapi-host/src/testing/playwright.ts | 6 +- .../truapi-host/src/web/create-mock-host.ts | 12 +- .../tests/golden/host-callbacks.ts | 26 +-- rust/crates/truapi-host-cli/js/scanner-e2e.ts | 90 --------- .../js/scripts/scanner-battery.ts | 77 ++++++-- rust/crates/truapi-host-cli/src/scanner.rs | 17 +- rust/crates/truapi/RUNTIME.md | 18 +- rust/crates/truapi/src/host_core.rs | 14 +- rust/crates/truapi/src/host_logic/scanner.rs | 187 +++++++----------- rust/crates/truapi/src/native/callbacks.rs | 15 +- rust/crates/truapi/src/native/runtime.rs | 8 +- rust/crates/truapi/src/platform.rs | 26 +-- rust/crates/truapi/src/runtime/tests.rs | 82 +++----- 16 files changed, 207 insertions(+), 423 deletions(-) delete mode 100644 rust/crates/truapi-host-cli/js/scanner-e2e.ts 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 f381255fd7..3c6240feb8 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 @@ -676,16 +676,8 @@ private class ContactsCallbackAdapter(private val bridge: ContactsHostBridge) : } /** - * Draws the host's viewfinder when a product scans a code. - * - * Title the viewfinder with [productId] and show `request.hint` under it as the - * product's words. Hand every code the camera reads to a `ScanFilter` built from - * [request] and act on its verdict. Never follow a scanned link. Close the - * viewfinder when the coroutine is cancelled. - * - * An App or Widget scans only while it is the screen the user sees: answer - * `HostScan.NotVisible` otherwise, without opening anything. A Worker's request - * has already passed the core's tap check, so open it over whatever is on screen. + * Draws the viewfinder for `scanner.scan`, following the rules on the core's + * `ScannerPlatform`. Closes it when the coroutine is cancelled. */ interface ScannerHostBridge { @Throws(HostRejection::class) @@ -769,11 +761,8 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor private var scannerRetainer: NativeScannerCallbacks? = null /** - * Install the host's scanner, which draws the viewfinder. - * - * Set-once, so the viewfinder cannot change hands under a running product. - * Returns whether this call installed it. Call it before opening any - * product execution. + * Install the host's scanner before opening any product execution. + * Set-once: returns whether this call installed it. */ fun setScanner(scanner: ScannerHostBridge): Boolean { val adapter = ScannerCallbackAdapter(scanner) diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 8f479f1410..3601543e35 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -303,18 +303,8 @@ public protocol ContactsHostBridge: AnyObject, Sendable { func pickContact(productId: String) async throws -> HostContactPick } -/// Draws the host's viewfinder when a product scans a code. Installed once on -/// ``TrUAPIHostRuntime/setScanner(_:)``. A runtime without one answers -/// `scanner.scan` with `Unsupported`. -/// -/// Title the viewfinder with `productId` and show `request.hint` under it as -/// the product's words. Hand every code the camera reads to a `ScanFilter` -/// built from `request` and act on its verdict. Never follow a scanned link. -/// Close the viewfinder when the task is cancelled. -/// -/// An App or Widget scans only while it is the screen the user sees: answer -/// `.notVisible` otherwise, without opening anything. A Worker's request has -/// already passed the core's tap check, so open it over whatever is on screen. +/// Draws the viewfinder for `scanner.scan`, following the rules on the core's +/// `ScannerPlatform`. Closes it when the task is cancelled. public protocol ScannerHostBridge: AnyObject, Sendable { func scanCode( productId: String, @@ -767,11 +757,8 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { return inner.setContactsCallbacks(callbacks: adapter) } - /// Install the host's scanner, which draws the viewfinder. - /// - /// Set-once, so the viewfinder cannot change hands under a running product. - /// Answers whether this call installed it. Call it before opening any - /// product execution. + /// Install the host's scanner before opening any product execution. + /// Set-once: answers whether this call installed it. @discardableResult public func setScanner(_ scanner: ScannerHostBridge) -> Bool { let adapter = ScannerCallbackAdapter(bridge: scanner) diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 0c86d66a54..cda5200b91 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -280,14 +280,10 @@ The core re-checks every account returned. It caches what it resolves, so call `notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). -`scanner.scanCode(product, request)` opens the host's viewfinder and answers -`Scanned { text, format }`, `Dismissed`, or `CameraUnavailable`. Title it with -the product id, show `request.hint` under the title as the product's words, and -never follow a scanned link. The core checks the request before calling and -checks the answer after, so a code the request does not accept never reaches -the product. The mock test host serves no scanner by default. Create it with -`createMockHost({ scanner: answer })` to serve one, and change the next answer -with `setScanAnswer(answer)`. +`scanner.scanCode(product, request)` opens the host's viewfinder. The rules it +follows are on `ScannerPlatform` in the generated callbacks. The mock test host +serves a scanner only when created with `createMockHost({ scanner: answer })`, +and `setScanAnswer(answer)` changes the next answer. ## Generated WASM artefacts diff --git a/js/packages/truapi-host/src/testing/playwright.ts b/js/packages/truapi-host/src/testing/playwright.ts index d3817ba54f..212b57fdab 100644 --- a/js/packages/truapi-host/src/testing/playwright.ts +++ b/js/packages/truapi-host/src/testing/playwright.ts @@ -266,11 +266,7 @@ export interface TestHost { clearPreimages(): Promise; getTheme(): Promise; setTheme(variant: string): Promise; - /** - * Set what the next product scan answers. Throws unless the mock was created - * with a `scanner` answer, because a host serves the scanner or not from - * the start. - */ + /** Set the next scan's answer. Throws unless created with a `scanner` answer. */ setScanAnswer(answer: HostScan): Promise; getIsAuthenticated(): Promise; getChainStatus(): Promise; diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index b6aaa45296..087ee74dad 100644 --- a/js/packages/truapi-host/src/web/create-mock-host.ts +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -400,10 +400,7 @@ export interface MockHostConfig { languageTag?: string; /** Whether `confirmUserAction` confirms reviewed actions. Default `true`. */ confirmUserActions?: boolean; - /** - * What the scanner answers a product's scan. Unset (the default) serves no - * scanner, so `scanner.scan` is `Unsupported`, as on a web host. - */ + /** The scanner's answer. Unset serves no scanner, so scans are `Unsupported`. */ scanner?: HostScan; /** * JSON-RPC response frames the chain connection replays, in order. Empty @@ -556,11 +553,7 @@ export interface MockHost { getTheme(): ThemeVariant; /** Replace the reported theme. */ setTheme(variant: ThemeVariant): void; - /** - * Set what the next product scan answers. Throws unless the mock was created - * with a `scanner` answer, because a host serves the scanner or not from - * the start. - */ + /** Set the next scan's answer. Throws unless created with a `scanner` answer. */ setScanAnswer(answer: HostScan): void; /** State of the mock's chain connection. */ getChainStatus(): ChainStatus; @@ -868,7 +861,6 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { }, } = config; - // Present only when the mock serves a scanner, so `setScanAnswer` can tell. const scan = scanner === undefined ? undefined : { answer: scanner }; const storage = new Map(); const preimages = new Map(); diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index aa5d1f6bb3..ba11e822fe 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -1709,23 +1709,17 @@ export interface ProductStorage { } /** - * Host-owned viewfinder for QR codes and barcodes. + * Host-owned viewfinder for QR codes and barcodes. Optional. The Swift and + * Kotlin bridges and the JS `scanner` callbacks follow the same rules. * - * Optional, and listed on `OptionalPlatform` as `ContactsPlatform` is. - * - * The host draws the viewfinder and titles it with the requesting product's - * id. Only `request.hint` is product text. It hands every code the camera - * reads to the core's `ScanFilter` and acts on its verdict, and never follows - * a scanned link itself. The core drops the returned future when the product - * cancels, and the host closes the viewfinder then. The next scan can arrive - * before that close has finished, so close any viewfinder still open before - * opening another. A JS host is not told about a cancel, because a promise - * cannot be withdrawn. - * - * An App or Widget scans only while it is the screen the user sees: answer - * `HostScan::NotVisible` otherwise, without opening anything. A Worker's - * request has already passed the core's tap check, so open it over whatever is - * on screen. + * - Title the viewfinder with the product id. Show `request.hint` under it as + * the product's words. + * - Pass every code the camera reads to a `ScanFilter` built from `request`, + * and never follow a scanned link. + * - Answer `HostScan::NotVisible` for an App or Widget that is not on + * screen. A Worker reaching the host already passed the core's tap check. + * - Close the viewfinder when the core drops the future, and close any still + * open before opening another. A JS host is not told about a drop. */ export interface ScannerPlatform { /** diff --git a/rust/crates/truapi-host-cli/js/scanner-e2e.ts b/rust/crates/truapi-host-cli/js/scanner-e2e.ts deleted file mode 100644 index d147a9ceab..0000000000 --- a/rust/crates/truapi-host-cli/js/scanner-e2e.ts +++ /dev/null @@ -1,90 +0,0 @@ -// End-to-end scanner checks, run against a CLI host that answers every scan -// with `TRUAPI_SCAN_TEXT` as a QR code. -// -// The CLI does not filter, so every case that refuses a code proves the core -// checked the host's answer, and every case that refuses a request proves the -// core checked it before asking the host. -import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; -import type { - CodeFormat, - HostScannerScanRequest, -} from "../../../../js/packages/truapi/src/generated/types.ts"; - -/** What the host scans in every case. */ -export const SCANNED_TEXT = "https://greenmarket.example/r/BAG6"; - -/** One case and how it ended. */ -export interface ScannerRow { - name: string; - pass: boolean; - output: string; -} - -type Expected = - | { scanned: { text: string; format: CodeFormat } } - | { domainError: "InvalidRequest" | "Unknown" }; - -interface Case { - name: string; - request: HostScannerScanRequest; - expected: Expected; -} - -const CASES: Case[] = [ - { - name: "a code that matches the request reaches the product", - request: { - formats: ["Qr"], - prefix: "https://greenmarket.example/r/", - hint: "Point at the receipt's QR code", - }, - expected: { scanned: { text: SCANNED_TEXT, format: "Qr" } }, - }, - { - name: "the prefix ignores letter case", - request: { formats: ["Qr"], prefix: "HTTPS://GREENMARKET.EXAMPLE/R/" }, - expected: { scanned: { text: SCANNED_TEXT, format: "Qr" } }, - }, - { - name: "a code without the prefix never reaches the product", - request: { formats: ["Qr"], prefix: "polkadotapp://pair" }, - expected: { domainError: "Unknown" }, - }, - { - name: "a code in a format the product did not ask for never reaches it", - request: { formats: ["Ean13"] }, - expected: { domainError: "Unknown" }, - }, - { - name: "a hint on two lines is refused before the host is asked", - request: { formats: ["Qr"], hint: "Scan the code on your computer\nto sign in" }, - expected: { domainError: "InvalidRequest" }, - }, - { - name: "a request with no formats is refused before the host is asked", - request: { formats: [] }, - expected: { domainError: "InvalidRequest" }, - }, -]; - -/** Run every case against `truapi`, which must not be signed in. */ -export async function runScannerE2e(truapi: TrUApiClient): Promise { - const rows: ScannerRow[] = []; - for (const { name, request, expected } of CASES) { - const result = await truapi.scanner.scan(request); - const actual = result.isOk() - ? JSON.stringify(result.value.outcome) - : JSON.stringify(result.error); - const pass = - "scanned" in expected - ? result.isOk() && - result.value.outcome.tag === "Scanned" && - result.value.outcome.value.text === expected.scanned.text && - result.value.outcome.value.format === expected.scanned.format - : result.isErr() && - result.error.tag === "Domain" && - result.error.value.value.tag === expected.domainError; - rows.push({ name, pass, output: actual }); - } - return rows; -} diff --git a/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts b/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts index 851acb161c..34d5bec2ae 100644 --- a/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts +++ b/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts @@ -1,31 +1,66 @@ /// -// Scanner checks against a real host, over the real wire. -// -// Run via: -// scripts/battery.sh --scanner-host -// -// which starts a signing host with `TRUAPI_SCAN_TEXT` set. The product never -// signs in, because scanning reads no account. -import { runScannerE2e, SCANNED_TEXT } from "../scanner-e2e.ts"; +// Scanner checks against a real host, run by `scripts/battery.sh --scanner-host`. +// The host scans SCANNED_TEXT in every case and does not filter, so a refused +// code proves the core checked the host's answer. The product never signs in. +import type { HostScannerScanRequest } from "../../../../../js/packages/truapi/src/generated/types.ts"; + +const SCANNED_TEXT = "https://greenmarket.example/r/BAG6"; if (process.env.TRUAPI_SCAN_TEXT !== SCANNED_TEXT) { - throw new Error( - `scanner battery needs TRUAPI_SCAN_TEXT=${SCANNED_TEXT}; run it through scripts/battery.sh --scanner-host`, - ); + throw new Error(`scanner battery needs TRUAPI_SCAN_TEXT=${SCANNED_TEXT}`); } -const rows = await runScannerE2e(truapi); -for (const row of rows) { - console.log(`${row.pass ? "✅" : "❌"} ${row.name}: ${row.output}`); +const SCANNED = { tag: "Scanned", value: { text: SCANNED_TEXT, format: "Qr" } }; + +const cases: [string, HostScannerScanRequest, string | typeof SCANNED][] = [ + [ + "a matching code reaches the product", + { formats: ["Qr"], prefix: "https://greenmarket.example/r/" }, + SCANNED, + ], + [ + "the prefix ignores letter case", + { formats: ["Qr"], prefix: "HTTPS://GREENMARKET.EXAMPLE/R/" }, + SCANNED, + ], + [ + "a code without the prefix is refused", + { formats: ["Qr"], prefix: "polkadotapp://pair" }, + "Unknown", + ], + [ + "a format the product did not ask for is refused", + { formats: ["Ean13"] }, + "Unknown", + ], + [ + "a two-line hint is refused before the host is asked", + { formats: ["Qr"], hint: "Scan this\nto sign in" }, + "InvalidRequest", + ], + [ + "no formats is refused before the host is asked", + { formats: [] }, + "InvalidRequest", + ], +]; + +let failures = 0; +for (const [name, request, expected] of cases) { + const result = await truapi.scanner.scan(request); + const actual = result.isOk() + ? result.value.outcome + : result.error.tag === "Domain" + ? result.error.value.value.tag + : result.error.tag; + const pass = JSON.stringify(actual) === JSON.stringify(expected); + if (!pass) failures++; + console.log(`${pass ? "✅" : "❌"} ${name}: ${JSON.stringify(actual)}`); } -const failures = rows.filter((row) => !row.pass); -if (failures.length > 0) { +if (failures > 0) { throw new Error( - `scanner battery failed: ${failures.length} of ${rows.length} cases\n${failures - .map((row) => `${row.name}: ${row.output}`) - .join("\n")}`, + `scanner battery failed: ${failures} of ${cases.length} cases`, ); } - -console.log(`scanner battery: ${rows.length} cases passed`); +console.log(`scanner battery: ${cases.length} cases passed`); diff --git a/rust/crates/truapi-host-cli/src/scanner.rs b/rust/crates/truapi-host-cli/src/scanner.rs index 37ac90f81b..d9e2cff1c8 100644 --- a/rust/crates/truapi-host-cli/src/scanner.rs +++ b/rust/crates/truapi-host-cli/src/scanner.rs @@ -1,10 +1,5 @@ -//! Scanner host for the CLI. -//! -//! A CLI has no camera, so the code it scans comes from `TRUAPI_SCAN_TEXT`, -//! read on every scan and reported as a QR code. With nothing set the user -//! dismisses, which lets the generated example and the battery pass on a bare -//! host. The host does not filter: the core refuses a configured code the -//! request does not accept. +//! Scanner host for the CLI. It has no camera, so every scan answers +//! `TRUAPI_SCAN_TEXT` as a QR code, or a dismissal when it is unset. use truapi::latest::{CodeFormat, GenericError, HostScannerScanRequest}; use truapi::platform::{HostScan, ProductContext, ScannerPlatform, async_trait}; @@ -40,13 +35,9 @@ mod tests { use super::*; #[test] - fn with_nothing_set_the_user_dismisses() { - // The generated example and the battery run on a bare CLI. + fn answers_the_configured_text_or_a_dismissal() { + // A bare CLI dismisses, so the generated example and the battery pass. assert_eq!(answer(None), HostScan::Dismissed); - } - - #[test] - fn a_set_text_is_scanned_as_a_qr_code() { assert_eq!( answer(Some("https://greenmarket.example/r/1".into())), HostScan::Scanned { diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 956b00ce3e..10b79cef19 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -413,19 +413,11 @@ AutoSigning without approval. Legacy-account signing still asks the user. one and delivers a notification otherwise, may add a calendar event, and keeps the reminder across app kill and reboot. A schedule the host cannot hold fails as a host failure carrying its reason. -- `ScannerPlatform`: open the host's QR and barcode viewfinder for a product - and report how it ended. The core checks the request first, answering - `Unsupported` without a scanner, `InvalidRequest` for a request over its - limits, `NotVisible` for a Worker the user has not tapped in the last 5 - seconds, and `Busy` while another scan is open, since the device has one - viewfinder. A tap is a renderer action the host delivered to that - connection. The core then calls the host, which answers `NotVisible` itself - for an App or Widget that is not on screen, and checks the code again, so a - code the request does not accept reaches the product as `Unknown`. A code - the core would accept as a pairing request is never accepted, whatever the - request asks for. A cancelled call drops the host's future, which is the - host's signal to close the viewfinder. The host filters codes with the - core's `ScanFilter` and never follows a scanned link. +- `ScannerPlatform`: open the host's QR and barcode viewfinder. Before calling + it the core refuses an invalid request, a Worker the user has not tapped in + the last 5 seconds, and a second open scan. After, it refuses a code the + request does not accept and any pairing request. The trait's docs list what + the host must do. `Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, `ContactsPlatform`, `PermissionStatusHost`, diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 3a0399fd67..98173441bc 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -287,11 +287,8 @@ impl PairingHostRuntime { self.services.install_game_platform(platform) } - /// Install the host's [`ScannerPlatform`], which draws the viewfinder. - /// - /// Set-once, so the viewfinder cannot change hands under a running product. - /// Returns whether this call installed it. Call it before serving any - /// product runtime. + /// Install the host's [`ScannerPlatform`] before serving any product + /// runtime. Set-once: returns whether this call installed it. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_scanner_platform"))] pub fn set_scanner_platform(&self, platform: Arc) -> bool { self.services.install_scanner_platform(platform) @@ -738,11 +735,8 @@ impl SigningHostRuntime { self.services.install_game_platform(platform) } - /// Install the host's [`ScannerPlatform`], which draws the viewfinder. - /// - /// Set-once, so the viewfinder cannot change hands under a running product. - /// Returns whether this call installed it. Call it before serving any - /// product runtime. + /// Install the host's [`ScannerPlatform`] before serving any product + /// runtime. Set-once: returns whether this call installed it. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_scanner_platform"))] pub fn set_scanner_platform(&self, platform: Arc) -> bool { self.services.install_scanner_platform(platform) diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index 3b475d2bc5..5980eb4e8d 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -138,60 +138,50 @@ mod tests { use super::*; fn request( - formats: Vec, + formats: &[CodeFormat], prefix: Option<&str>, hint: Option<&str>, ) -> HostScannerScanRequest { HostScannerScanRequest { - formats, + formats: formats.to_vec(), prefix: prefix.map(str::to_owned), hint: hint.map(str::to_owned), } } - fn qr(prefix: Option<&str>, hint: Option<&str>) -> HostScannerScanRequest { - request(vec![CodeFormat::Qr], prefix, hint) + fn valid(formats: &[CodeFormat], prefix: Option<&str>, hint: Option<&str>) -> bool { + validate_request(&request(formats, prefix, hint)).is_ok() } - fn receipts() -> HostScannerScanRequest { - qr(Some("https://greenmarket.example/r/"), None) - } + const RECEIPTS: &str = "https://greenmarket.example/r/"; #[test] - fn a_request_must_name_a_format() { - // With no formats the host, not the product, would decide what comes back. - assert!(validate_request(&request(vec![], None, None)).is_err()); - assert!(validate_request(&qr(None, None)).is_ok()); + fn formats_are_required_and_named_once() { + // With no formats the host would decide what comes back, and the filter + // checks the list on every camera frame. + assert!(!valid(&[], None, None)); + assert!(!valid(&[CodeFormat::Qr, CodeFormat::Qr], None, None)); + assert!(valid(&[CodeFormat::Qr, CodeFormat::Ean13], None, None)); } #[test] - fn a_format_may_be_named_only_once() { - // The filter checks the list on every camera frame, so it stays as - // short as the formats themselves. - assert!( - validate_request(&request(vec![CodeFormat::Qr, CodeFormat::Qr], None, None)).is_err() - ); - assert!( - validate_request(&request( - vec![CodeFormat::Qr, CodeFormat::Ean13], - None, - None - )) - .is_ok() - ); - } - - #[test] - fn the_hint_is_counted_in_unicode_scalars() { - // TypeScript products count with `[...hint].length`, which agrees with this. - assert!(validate_request(&qr(None, Some(&"é".repeat(MAX_HINT_CHARS)))).is_ok()); - assert!(validate_request(&qr(None, Some(&"a".repeat(MAX_HINT_CHARS + 1)))).is_err()); + fn limits_count_the_hint_in_scalars_and_the_prefix_in_bytes() { + // TypeScript products count a hint with `[...hint].length`, which agrees. + let qr = &[CodeFormat::Qr]; + assert!(valid(qr, None, Some(&"é".repeat(MAX_HINT_CHARS)))); + assert!(!valid(qr, None, Some(&"a".repeat(MAX_HINT_CHARS + 1)))); + assert!(valid(qr, Some(&"a".repeat(MAX_PREFIX_BYTES)), None)); + assert!(!valid( + qr, + Some(&"é".repeat(MAX_PREFIX_BYTES / 2 + 1)), + None + )); } #[test] fn the_hint_stays_one_plain_line() { - // The hint sits under the host's title, so text that breaks the line or - // flips its direction could pass for a host instruction. + // The hint sits under the host's title, so text that breaks the line, + // flips its direction or hides itself could pass for host text. for refused in [ "scan\nto sign in", "tab\there", @@ -207,90 +197,42 @@ mod tests { "\u{FFF9}annotation", ] { assert!( - validate_request(&qr(None, Some(refused))).is_err(), - "{refused:?} was allowed" + !valid(&[CodeFormat::Qr], None, Some(refused)), + "{refused:?}" ); } - assert!(validate_request(&qr(None, Some("Point at the receipt's QR code"))).is_ok()); - } - - #[test] - fn the_prefix_is_counted_in_bytes() { - assert!(validate_request(&qr(Some(&"a".repeat(MAX_PREFIX_BYTES)), None)).is_ok()); - let over = "é".repeat(MAX_PREFIX_BYTES / 2 + 1); - assert!(validate_request(&qr(Some(&over), None)).is_err()); - } - - #[test] - fn a_code_is_accepted_only_in_a_named_format() { - assert!(accepts(&qr(None, None), CodeFormat::Qr, "anything")); - assert!(!accepts( - &qr(None, None), - CodeFormat::Ean13, - "4006381333931" + assert!(valid( + &[CodeFormat::Qr], + None, + Some("Point at the receipt's QR code") )); } #[test] - fn the_prefix_ignores_letter_case_and_nothing_else() { + fn a_code_must_have_a_named_format_and_the_prefix_in_any_letter_case() { // QR codes often store URLs in capitals to fit more in the code. - assert!(accepts( - &receipts(), - CodeFormat::Qr, - "https://greenmarket.example/r/BAG6" - )); - assert!(accepts( - &receipts(), - CodeFormat::Qr, - "HTTPS://GREENMARKET.EXAMPLE/R/BAG6" - )); - assert!(!accepts( - &receipts(), - CodeFormat::Qr, - "polkadotapp://pair?handshake=00" - )); + let receipts = request(&[CodeFormat::Qr], Some(RECEIPTS), None); + for (format, text, accepted) in [ + (CodeFormat::Qr, "https://greenmarket.example/r/BAG6", true), + (CodeFormat::Qr, "HTTPS://GREENMARKET.EXAMPLE/R/BAG6", true), + ( + CodeFormat::Ean13, + "https://greenmarket.example/r/BAG6", + false, + ), + (CodeFormat::Qr, "https://greenmarket.example", false), + (CodeFormat::Qr, "https://elsewhere.example/r/", false), + ] { + assert_eq!(accepts(&receipts, format, text), accepted, "{text}"); + } + // A byte-length cut through a multi-byte character refuses, not panics. assert!(!accepts( - &receipts(), + &request(&[CodeFormat::Qr], Some("ab"), None), CodeFormat::Qr, - "https://greenmarket.example" + "aé" )); } - #[test] - fn a_prefix_that_ends_inside_a_character_does_not_panic() { - // A byte-length cut through a multi-byte character must refuse, not crash. - assert!(!accepts(&qr(Some("ab"), None), CodeFormat::Qr, "aé")); - } - - #[test] - fn the_filter_accepts_once() { - // The viewfinder sees the same code on many frames before it closes. - let filter = ScanFilter::new(receipts()); - let receipt = "https://greenmarket.example/r/BAG6".to_owned(); - assert_eq!( - filter.observe(CodeFormat::Qr, receipt.clone()), - ScanVerdict::Accept - ); - assert_eq!(filter.observe(CodeFormat::Qr, receipt), ScanVerdict::Ignore); - } - - #[test] - fn the_filter_names_each_wrong_code_once_per_scan() { - // A poster with two codes on it is read alternately, frame after frame. - // The user should hear about each once, not on every frame. - let filter = ScanFilter::new(receipts()); - let poster = "https://elsewhere.example".to_owned(); - let pairing = "polkadotapp://pair?handshake=00".to_owned(); - for (code, expected) in [ - (&poster, ScanVerdict::NotForThisProduct), - (&pairing, ScanVerdict::NotForThisProduct), - (&poster, ScanVerdict::Ignore), - (&pairing, ScanVerdict::Ignore), - ] { - assert_eq!(filter.observe(CodeFormat::Qr, code.clone()), expected); - } - } - #[test] fn a_pairing_request_never_reaches_a_product() { // Whoever answers a pairing link pairs with the device that showed it, @@ -305,22 +247,35 @@ mod tests { let other_scheme = link.replacen("polkadotapp", "otherwallet", 1); let bare_handshake = link.split_once("?handshake=").unwrap().1.to_owned(); for text in [&link, &other_scheme, &bare_handshake] { - assert!(!accepts(&qr(None, None), CodeFormat::Qr, text), "{text}"); - assert!(!accepts(&qr(Some("polkadotapp://pair?"), None), CodeFormat::Qr, text)); + for prefix in [None, Some("polkadotapp://pair?")] { + let any_qr = request(&[CodeFormat::Qr], prefix, None); + assert!(!accepts(&any_qr, CodeFormat::Qr, text), "{text}"); + } } - assert_eq!( - ScanFilter::new(qr(None, None)).observe(CodeFormat::Qr, link), - ScanVerdict::NotForThisProduct - ); } #[test] - fn the_filter_still_accepts_after_refusing() { - let filter = ScanFilter::new(receipts()); - filter.observe(CodeFormat::Qr, "https://elsewhere.example".to_owned()); + fn the_filter_accepts_once_and_names_each_wrong_code_once() { + // The camera reads the same codes frame after frame, and a poster with + // two codes is read in turn. The user hears about each code once. + let filter = ScanFilter::new(request(&[CodeFormat::Qr], Some(RECEIPTS), None)); + let observe = |text: &str| filter.observe(CodeFormat::Qr, text.to_owned()); + assert_eq!( + observe("https://elsewhere.example"), + ScanVerdict::NotForThisProduct + ); assert_eq!( - filter.observe(CodeFormat::Qr, "https://greenmarket.example/r/1".to_owned()), + observe("https://other.example"), + ScanVerdict::NotForThisProduct + ); + assert_eq!(observe("https://elsewhere.example"), ScanVerdict::Ignore); + assert_eq!( + observe("https://greenmarket.example/r/1"), ScanVerdict::Accept ); + assert_eq!( + observe("https://greenmarket.example/r/1"), + ScanVerdict::Ignore + ); } } diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 99a8a468fa..870382b22f 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -326,19 +326,8 @@ pub trait NativeContactsCallbacks: Send + Sync { ) -> Result; } -/// Native scanner adapter. A host that can draw a viewfinder passes an -/// implementation to [`NativeTrUApiHostRuntime::set_scanner_callbacks`]; one -/// without leaves `scanner.scan` answering `Unsupported`. -/// -/// Title the viewfinder with `product_id` and show `request.hint` under it as -/// the product's words. Hand every code the camera reads to a -/// [`crate::host_logic::scanner::ScanFilter`] built from `request`, and never -/// follow a scanned link. Dropping the returned future cancels the foreign -/// task; close the viewfinder when that happens. -/// -/// An App or Widget scans only while it is the screen the user sees: answer -/// `NotVisible` otherwise, without opening anything. A Worker's request has -/// already passed the core's tap check, so open it over whatever is on screen. +/// Native form of [`crate::platform::ScannerPlatform`], whose rules it follows. +/// Installed with [`NativeTrUApiHostRuntime::set_scanner_callbacks`]. #[uniffi::export(rust, foreign)] #[async_trait::async_trait] pub trait NativeScannerCallbacks: Send + Sync { diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index d99009b0d7..4b9e254d24 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -276,12 +276,8 @@ impl NativeTrUApiHostRuntime { })) } - /// Install the host's scanner, which draws the viewfinder. - /// - /// Set-once, so the viewfinder cannot change hands under a running - /// product. Answers whether this call installed it. Call it before opening - /// any product execution; a runtime without one answers `scanner.scan` - /// with `Unsupported`. + /// Install the host's scanner before opening any product execution. + /// Set-once: answers whether this call installed it. pub fn set_scanner_callbacks(&self, callbacks: Arc) -> bool { self.runtime .set_scanner_platform(Arc::new(ScannerCallbackPlatform { scanner: callbacks })) diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index c0e29bba41..69d46dfe35 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3515,23 +3515,17 @@ pub enum HostScan { NotVisible, } -/// Host-owned viewfinder for QR codes and barcodes. +/// Host-owned viewfinder for QR codes and barcodes. Optional. The Swift and +/// Kotlin bridges and the JS `scanner` callbacks follow the same rules. /// -/// Optional, and listed on [`OptionalPlatform`] as [`ContactsPlatform`] is. -/// -/// The host draws the viewfinder and titles it with the requesting product's -/// id. Only `request.hint` is product text. It hands every code the camera -/// reads to the core's `ScanFilter` and acts on its verdict, and never follows -/// a scanned link itself. The core drops the returned future when the product -/// cancels, and the host closes the viewfinder then. The next scan can arrive -/// before that close has finished, so close any viewfinder still open before -/// opening another. A JS host is not told about a cancel, because a promise -/// cannot be withdrawn. -/// -/// An App or Widget scans only while it is the screen the user sees: answer -/// [`HostScan::NotVisible`] otherwise, without opening anything. A Worker's -/// request has already passed the core's tap check, so open it over whatever is -/// on screen. +/// - Title the viewfinder with the product id. Show `request.hint` under it as +/// the product's words. +/// - Pass every code the camera reads to a `ScanFilter` built from `request`, +/// and never follow a scanned link. +/// - Answer [`HostScan::NotVisible`] for an App or Widget that is not on +/// screen. A Worker reaching the host already passed the core's tap check. +/// - Close the viewfinder when the core drops the future, and close any still +/// open before opening another. A JS host is not told about a drop. #[async_trait] pub trait ScannerPlatform: Send + Sync { /// Open the viewfinder on behalf of `product` and wait for the user. diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 627ca6a6e7..8814243091 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -4,7 +4,8 @@ use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use crate::platform::{ - AuthState, CoreStorage as PlatformCoreStorage, CoreStorageKey, PermissionAuthorizationRequest, + AuthState, CoreStorage as PlatformCoreStorage, CoreStorageKey, HostScan, + PermissionAuthorizationRequest, ProductExecutionKind, }; use parity_scale_codec::Encode; use truapi::api::{ @@ -7690,14 +7691,14 @@ fn an_internal_cancellation_never_becomes_the_cancelled_variant() { /// asked for. A parked one never answers, standing in for a user who has not /// scanned yet, and records when the core stops waiting on it. struct StubScannerPlatform { - answer: crate::platform::HostScan, + answer: HostScan, park: bool, asked: Mutex>, abandoned: Arc, } impl StubScannerPlatform { - fn answering(answer: crate::platform::HostScan) -> Arc { + fn answering(answer: HostScan) -> Arc { Arc::new(Self { answer, park: false, @@ -7707,12 +7708,9 @@ impl StubScannerPlatform { } fn parked() -> Arc { - Arc::new(Self { - answer: crate::platform::HostScan::Dismissed, - park: true, - asked: Mutex::new(Vec::new()), - abandoned: Arc::default(), - }) + let mut stub = Arc::into_inner(Self::answering(HostScan::Dismissed)).unwrap(); + stub.park = true; + Arc::new(stub) } fn asked(&self) -> Vec<(String, truapi::latest::HostScannerScanRequest)> { @@ -7736,7 +7734,7 @@ impl crate::platform::ScannerPlatform for StubScannerPlatform { &self, product: &crate::platform::ProductContext, request: &truapi::latest::HostScannerScanRequest, - ) -> Result { + ) -> Result { self.asked .lock() .unwrap() @@ -7752,12 +7750,12 @@ impl crate::platform::ScannerPlatform for StubScannerPlatform { /// A runtime for `greenmarket.dot` with no session, and `scanner` installed /// when given. fn scanner_host(scanner: Option>) -> ProductRuntimeHost { - scanner_host_for(crate::platform::ProductExecutionKind::App, scanner) + scanner_host_for(ProductExecutionKind::App, scanner) } /// [`scanner_host`] for an execution of `kind`. fn scanner_host_for( - kind: crate::platform::ProductExecutionKind, + kind: ProductExecutionKind, scanner: Option>, ) -> ProductRuntimeHost { let (host_config, mut product) = runtime_config("greenmarket.dot"); @@ -7787,18 +7785,6 @@ fn receipt_request() -> truapi::latest::HostScannerScanRequest { } } -fn scan_in( - host: &ProductRuntimeHost, - cx: &CallContext, -) -> Result> { - let result = futures::executor::block_on(Scanner::scan( - host, - cx, - HostScannerScanRequest::V1(receipt_request()), - )); - unwrap_scan(result) -} - fn unwrap_scan( result: Result>, ) -> Result> { @@ -7814,7 +7800,11 @@ fn unwrap_scan( fn scan( host: &ProductRuntimeHost, ) -> Result> { - scan_in(host, &CallContext::default()) + unwrap_scan(futures::executor::block_on(Scanner::scan( + host, + &CallContext::default(), + HostScannerScanRequest::V1(receipt_request()), + ))) } /// The user tapping a button on the product's card face. @@ -7834,11 +7824,8 @@ fn card_tap() -> truapi::versioned::renderer::HostRendererActionSubscribeItem { fn a_worker_scans_only_right_after_the_user_taps_its_card() { // A Worker has no screen of its own. Without a tap, the viewfinder would // open over whatever the user is doing. - let scanner = StubScannerPlatform::answering(crate::platform::HostScan::Dismissed); - let host = scanner_host_for( - crate::platform::ProductExecutionKind::Worker, - Some(scanner.clone()), - ); + let scanner = StubScannerPlatform::answering(HostScan::Dismissed); + let host = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); assert_eq!( scan(&host), Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) @@ -7852,14 +7839,9 @@ fn a_worker_scans_only_right_after_the_user_taps_its_card() { #[test] fn a_tap_older_than_the_window_does_not_let_a_worker_scan() { - let scanner = StubScannerPlatform::answering(crate::platform::HostScan::Dismissed); - let host = scanner_host_for( - crate::platform::ProductExecutionKind::Worker, - Some(scanner.clone()), - ); - host.note_user_tap_at( - crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1, - ); + let scanner = StubScannerPlatform::answering(HostScan::Dismissed); + let host = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); + host.note_user_tap_at(crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1); assert_eq!( scan(&host), Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) @@ -7870,17 +7852,15 @@ fn a_tap_older_than_the_window_does_not_let_a_worker_scan() { #[test] fn a_page_the_host_says_is_off_screen_is_not_visible() { // Only the host knows which page the user is looking at. - let host = scanner_host(Some(StubScannerPlatform::answering( - crate::platform::HostScan::NotVisible, - ))); + let host = scanner_host(Some(StubScannerPlatform::answering(HostScan::NotVisible))); assert_eq!( scan(&host), Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) ); } -fn scanned(text: &str, format: v01::CodeFormat) -> crate::platform::HostScan { - crate::platform::HostScan::Scanned { +fn scanned(text: &str, format: v01::CodeFormat) -> HostScan { + HostScan::Scanned { text: text.into(), format, } @@ -7920,13 +7900,11 @@ fn a_scan_returns_the_code_and_names_the_product_without_a_session() { #[test] fn a_dismissal_is_an_outcome_and_a_missing_camera_is_an_error() { // The product offers a dismissed scan again, but not one with no camera. - let dismissed = scanner_host(Some(StubScannerPlatform::answering( - crate::platform::HostScan::Dismissed, - ))); + let dismissed = scanner_host(Some(StubScannerPlatform::answering(HostScan::Dismissed))); assert_eq!(scan(&dismissed), Ok(v01::ScanOutcome::Dismissed)); let no_camera = scanner_host(Some(StubScannerPlatform::answering( - crate::platform::HostScan::CameraUnavailable, + HostScan::CameraUnavailable, ))); assert_eq!( scan(&no_camera), @@ -7939,7 +7917,7 @@ fn a_dismissal_is_an_outcome_and_a_missing_camera_is_an_error() { #[test] fn an_invalid_request_never_reaches_the_host() { // No viewfinder may open for a request the product could not make. - let scanner = StubScannerPlatform::answering(crate::platform::HostScan::Dismissed); + let scanner = StubScannerPlatform::answering(HostScan::Dismissed); let host = scanner_host(Some(scanner.clone())); let mut request = receipt_request(); request.hint = Some("Scan the code on your computer\nto sign in".into()); @@ -8085,12 +8063,8 @@ fn a_pairing_test_host_keeps_a_submitted_preimage_and_serves_it_back() { let session = sso_session_info(); let platform = Arc::new(StubPlatform::default()); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( - platform, - host_config, - product, - test_spawner(), - ); + let (host, pairing_host) = + ProductRuntimeHost::new_pairing_for_tests(platform, host_config, product, test_spawner()); install_pairing_session(&host, session.clone()); let lifecycle_epoch = pairing_host.current_session_lifecycle_epoch(); futures::executor::block_on(pairing_host.cache_bulletin_allowance_key( From 24a3ad7e85ffedfe1f7406111517674311c4fcc1 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Wed, 7 Oct 2026 15:29:46 -0300 Subject: [PATCH 22/29] test: merge overlapping scanner tests Same behaviour covered with fewer tests: - The runtime host-answer tests become one table, and the two Worker tap tests one test. - The request validation tests become one. - The Rust mock and CLI scanner unit tests are gone: the mock parity test and the CLI battery already exercise both. - The CLI battery keeps one case each for accepted, refused by the core and refused before the host, since unit tests cover the variations. - The two mock client scanner tests become one. --- README.md | 2 +- .../src/testing/create-mock-client.test.ts | 65 +++---- .../js/scripts/scanner-battery.ts | 15 -- rust/crates/truapi-host-cli/src/scanner.rs | 36 +--- rust/crates/truapi/src/host_logic/scanner.rs | 46 ++--- rust/crates/truapi/src/platform/mock.rs | 24 --- rust/crates/truapi/src/runtime/tests.rs | 166 +++++++----------- 7 files changed, 112 insertions(+), 242 deletions(-) diff --git a/README.md b/README.md index c2811e3049..ee49a320cd 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ Diagnosis skips it, since dot.li has no viewfinder and answers `Unsupported`. `make e2e-scanner-cli` runs the scanner phase on both CLI host roles, the pairing host unpaired since scanning needs no session. The host scans `https://greenmarket.example/r/BAG6`, and the cases check that a matching code -reaches the product, that a code or format the product did not ask for never +reaches the product, that a code the product did not ask for never does, and that an invalid request is refused before the host is asked. To run the playground locally in a plain browser tab, against a signing host on diff --git a/js/packages/truapi-host/src/testing/create-mock-client.test.ts b/js/packages/truapi-host/src/testing/create-mock-client.test.ts index c68fc25779..af473d8301 100644 --- a/js/packages/truapi-host/src/testing/create-mock-client.test.ts +++ b/js/packages/truapi-host/src/testing/create-mock-client.test.ts @@ -15,7 +15,9 @@ function hex(bytes: Uint8Array): `0x${string}` { return `0x${digits.join("")}`; } -const suite = wasmIsBuilt("testing/truapi_server.js") ? describe : describe.skip; +const suite = wasmIsBuilt("testing/truapi_server.js") + ? describe + : describe.skip; suite("createMockClient", () => { it("round-trips a product call to the mock host", async () => { @@ -56,46 +58,29 @@ suite("createMockClient", () => { } }); - it("answers a product's scan with the code the test configured", async () => { - // The browser joins for an optional capability are written by hand, so a - // missing one leaves every product scan `Unsupported` while unit tests - // pass. Only a call through the real core proves the scanner is reachable. - const { client, host, dispose } = await createMockClient({ - mock: { - scanner: { - tag: "Scanned", - value: { text: "https://greenmarket.example/r/1", format: "Qr" }, - }, - }, - }); + it("serves a scanner only when the test configures one", async () => { + // The browser joins for an optional capability are written by hand, so + // only a call through the real core proves the scanner is reachable. + const receipt = { + tag: "Scanned", + value: { text: "https://greenmarket.example/r/1", format: "Qr" }, + } as const; + const served = await createMockClient({ mock: { scanner: receipt } }); + const bare = await createMockClient(); try { - const first = await client.scanner.scan({ - formats: ["Qr"], - prefix: "https://greenmarket.example/r/", + const scan = () => served.client.scanner.scan({ formats: ["Qr"] }); + expect((await scan())._unsafeUnwrap().outcome).toEqual(receipt); + served.host.setScanAnswer({ tag: "Dismissed" }); + expect((await scan())._unsafeUnwrap().outcome).toEqual({ + tag: "Dismissed", }); - expect(first._unsafeUnwrap().outcome).toEqual({ - tag: "Scanned", - value: { text: "https://greenmarket.example/r/1", format: "Qr" }, - }); - - // A "Scan another" flow: the next scan gets the next answer. - host.setScanAnswer({ tag: "Dismissed" }); - const second = await client.scanner.scan({ formats: ["Qr"] }); - expect(second._unsafeUnwrap().outcome).toEqual({ tag: "Dismissed" }); - } finally { - dispose(); - } - }); - it("leaves scanning unsupported when the test configures no scanner", async () => { - // A suite that never mentions scanning sees what a web host answers. - const { client, host, dispose } = await createMockClient(); - try { - const result = await client.scanner.scan({ formats: ["Qr"] }); - expect(result._unsafeUnwrapErr().tag).toBe("Unsupported"); - expect(() => host.setScanAnswer({ tag: "Dismissed" })).toThrow(); + const unserved = await bare.client.scanner.scan({ formats: ["Qr"] }); + expect(unserved._unsafeUnwrapErr().tag).toBe("Unsupported"); + expect(() => bare.host.setScanAnswer({ tag: "Dismissed" })).toThrow(); } finally { - dispose(); + served.dispose(); + bare.dispose(); } }); @@ -151,7 +136,8 @@ suite("createMockClient", () => { // The core caches a decided authorization, so a product asking twice is // answered from that record and the second call never reaches the host. - const repeated = await client.permissions.requestDevicePermission("Camera"); + const repeated = + await client.permissions.requestDevicePermission("Camera"); expect(repeated._unsafeUnwrap().granted).toBe(false); expect( host.getPermissionLog(), @@ -163,7 +149,8 @@ suite("createMockClient", () => { // the host afresh. Without this a suite can only ever observe the answer // its first request happened to settle on. host.grantPermission("Camera"); - const granted = await client.permissions.requestDevicePermission("Camera"); + const granted = + await client.permissions.requestDevicePermission("Camera"); expect(granted._unsafeUnwrap().granted).toBe(true); expect(host.getPermissionLog()).toHaveLength(2); } finally { diff --git a/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts b/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts index 34d5bec2ae..2d33f4f2c5 100644 --- a/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts +++ b/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts @@ -18,31 +18,16 @@ const cases: [string, HostScannerScanRequest, string | typeof SCANNED][] = [ { formats: ["Qr"], prefix: "https://greenmarket.example/r/" }, SCANNED, ], - [ - "the prefix ignores letter case", - { formats: ["Qr"], prefix: "HTTPS://GREENMARKET.EXAMPLE/R/" }, - SCANNED, - ], [ "a code without the prefix is refused", { formats: ["Qr"], prefix: "polkadotapp://pair" }, "Unknown", ], - [ - "a format the product did not ask for is refused", - { formats: ["Ean13"] }, - "Unknown", - ], [ "a two-line hint is refused before the host is asked", { formats: ["Qr"], hint: "Scan this\nto sign in" }, "InvalidRequest", ], - [ - "no formats is refused before the host is asked", - { formats: [] }, - "InvalidRequest", - ], ]; let failures = 0; diff --git a/rust/crates/truapi-host-cli/src/scanner.rs b/rust/crates/truapi-host-cli/src/scanner.rs index d9e2cff1c8..ca427f14ac 100644 --- a/rust/crates/truapi-host-cli/src/scanner.rs +++ b/rust/crates/truapi-host-cli/src/scanner.rs @@ -14,36 +14,14 @@ impl ScannerPlatform for CliScannerHost { product: &ProductContext, _request: &HostScannerScanRequest, ) -> Result { - let answer = answer(std::env::var("TRUAPI_SCAN_TEXT").ok()); + let answer = match std::env::var("TRUAPI_SCAN_TEXT") { + Ok(text) => HostScan::Scanned { + text, + format: CodeFormat::Qr, + }, + Err(_) => HostScan::Dismissed, + }; tracing::info!(product = %product.product_id, ?answer, "scan answered"); Ok(answer) } } - -fn answer(text: Option) -> HostScan { - match text { - Some(text) => HostScan::Scanned { - text, - format: CodeFormat::Qr, - }, - None => HostScan::Dismissed, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn answers_the_configured_text_or_a_dismissal() { - // A bare CLI dismisses, so the generated example and the battery pass. - assert_eq!(answer(None), HostScan::Dismissed); - assert_eq!( - answer(Some("https://greenmarket.example/r/1".into())), - HostScan::Scanned { - text: "https://greenmarket.example/r/1".into(), - format: CodeFormat::Qr, - } - ); - } -} diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index 5980eb4e8d..cdf01997a9 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -156,32 +156,30 @@ mod tests { const RECEIPTS: &str = "https://greenmarket.example/r/"; #[test] - fn formats_are_required_and_named_once() { - // With no formats the host would decide what comes back, and the filter - // checks the list on every camera frame. + fn a_request_names_each_format_once_and_keeps_its_hint_to_one_plain_line() { + // With no formats the host would decide what comes back. The hint sits + // under the host's title, so text that breaks the line, flips its + // direction or hides itself could pass for host text. TypeScript counts + // a hint with `[...hint].length`, which agrees with the scalar count. + let qr = &[CodeFormat::Qr]; + assert!(valid( + &[CodeFormat::Qr, CodeFormat::Ean13], + None, + Some("Point at the receipt") + )); + assert!(valid( + qr, + Some(&"a".repeat(MAX_PREFIX_BYTES)), + Some(&"é".repeat(MAX_HINT_CHARS)) + )); assert!(!valid(&[], None, None)); assert!(!valid(&[CodeFormat::Qr, CodeFormat::Qr], None, None)); - assert!(valid(&[CodeFormat::Qr, CodeFormat::Ean13], None, None)); - } - - #[test] - fn limits_count_the_hint_in_scalars_and_the_prefix_in_bytes() { - // TypeScript products count a hint with `[...hint].length`, which agrees. - let qr = &[CodeFormat::Qr]; - assert!(valid(qr, None, Some(&"é".repeat(MAX_HINT_CHARS)))); - assert!(!valid(qr, None, Some(&"a".repeat(MAX_HINT_CHARS + 1)))); - assert!(valid(qr, Some(&"a".repeat(MAX_PREFIX_BYTES)), None)); assert!(!valid( qr, Some(&"é".repeat(MAX_PREFIX_BYTES / 2 + 1)), None )); - } - - #[test] - fn the_hint_stays_one_plain_line() { - // The hint sits under the host's title, so text that breaks the line, - // flips its direction or hides itself could pass for host text. + assert!(!valid(qr, None, Some(&"a".repeat(MAX_HINT_CHARS + 1)))); for refused in [ "scan\nto sign in", "tab\there", @@ -196,16 +194,8 @@ mod tests { "\u{FEFF}byte order mark", "\u{FFF9}annotation", ] { - assert!( - !valid(&[CodeFormat::Qr], None, Some(refused)), - "{refused:?}" - ); + assert!(!valid(qr, None, Some(refused)), "{refused:?}"); } - assert!(valid( - &[CodeFormat::Qr], - None, - Some("Point at the receipt's QR code") - )); } #[test] diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index bec721cfe4..1cd835f723 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -1799,30 +1799,6 @@ mod tests { assert!(stream.next().now_or_never().is_none()); } - #[test] - fn the_scanner_answers_what_the_test_set() { - // A product's "Scan another" flow needs each scan to get its own answer. - let p = MockPlatform::new(); - let request = latest::HostScannerScanRequest { - formats: vec![latest::CodeFormat::Qr], - prefix: None, - hint: None, - }; - assert_eq!( - block_on(p.scan_code(&mock_product(), &request)), - Ok(HostScan::Dismissed) - ); - let receipt = HostScan::Scanned { - text: "https://greenmarket.example/r/1".into(), - format: latest::CodeFormat::Qr, - }; - p.set_scan_answer(receipt.clone()); - assert_eq!( - block_on(p.scan_code(&mock_product(), &request)), - Ok(receipt) - ); - } - #[test] fn theme_emits_configured_light() { let p = MockPlatform::with_config(MockConfig { diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 8814243091..392280fd24 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7820,45 +7820,6 @@ fn card_tap() -> truapi::versioned::renderer::HostRendererActionSubscribeItem { ) } -#[test] -fn a_worker_scans_only_right_after_the_user_taps_its_card() { - // A Worker has no screen of its own. Without a tap, the viewfinder would - // open over whatever the user is doing. - let scanner = StubScannerPlatform::answering(HostScan::Dismissed); - let host = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); - assert_eq!( - scan(&host), - Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) - ); - assert!(scanner.asked().is_empty()); - - host.publish_renderer_action(card_tap()).unwrap(); - assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); - assert_eq!(scanner.asked().len(), 1); -} - -#[test] -fn a_tap_older_than_the_window_does_not_let_a_worker_scan() { - let scanner = StubScannerPlatform::answering(HostScan::Dismissed); - let host = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); - host.note_user_tap_at(crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1); - assert_eq!( - scan(&host), - Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) - ); - assert!(scanner.asked().is_empty()); -} - -#[test] -fn a_page_the_host_says_is_off_screen_is_not_visible() { - // Only the host knows which page the user is looking at. - let host = scanner_host(Some(StubScannerPlatform::answering(HostScan::NotVisible))); - assert_eq!( - scan(&host), - Err(CallError::Domain(v01::HostScannerScanError::NotVisible)) - ); -} - fn scanned(text: &str, format: v01::CodeFormat) -> HostScan { HostScan::Scanned { text: text.into(), @@ -7867,93 +7828,86 @@ fn scanned(text: &str, format: v01::CodeFormat) -> HostScan { } #[test] -fn scanning_without_a_scanner_is_unsupported() { - // A product reads `Unsupported` as "use your own camera code"; a - // `HostFailure` would read as a real failure. - let host = scanner_host(None); - assert_eq!(scan(&host), Err(CallError::Unsupported)); -} +fn a_worker_scans_only_shortly_after_the_user_taps_its_card() { + // A Worker has no screen of its own. Without a recent tap, the viewfinder + // would open over whatever the user is doing. + let scanner = StubScannerPlatform::answering(HostScan::Dismissed); + let host = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); + let not_visible = Err(CallError::Domain(v01::HostScannerScanError::NotVisible)); + assert_eq!(scan(&host), not_visible); -#[test] -fn a_scan_returns_the_code_and_names_the_product_without_a_session() { - // No session is installed: scanning reads no account, so it works signed out. - let scanner = StubScannerPlatform::answering(scanned( - "https://greenmarket.example/r/BAG6", - v01::CodeFormat::Qr, - )); - let host = scanner_host(Some(scanner.clone())); + host.note_user_tap_at(crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1); + assert_eq!(scan(&host), not_visible); + assert!(scanner.asked().is_empty()); - assert_eq!( - scan(&host), - Ok(v01::ScanOutcome::Scanned { - text: "https://greenmarket.example/r/BAG6".into(), - format: v01::CodeFormat::Qr, - }) - ); - // The host titles the viewfinder with this id, so it must be the caller's. - assert_eq!( - scanner.asked(), - vec![("greenmarket.dot".to_owned(), receipt_request())] - ); + host.publish_renderer_action(card_tap()).unwrap(); + assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); } #[test] -fn a_dismissal_is_an_outcome_and_a_missing_camera_is_an_error() { - // The product offers a dismissed scan again, but not one with no camera. - let dismissed = scanner_host(Some(StubScannerPlatform::answering(HostScan::Dismissed))); - assert_eq!(scan(&dismissed), Ok(v01::ScanOutcome::Dismissed)); - - let no_camera = scanner_host(Some(StubScannerPlatform::answering( - HostScan::CameraUnavailable, - ))); - assert_eq!( - scan(&no_camera), - Err(CallError::Domain( - v01::HostScannerScanError::CameraUnavailable - )) - ); +fn each_host_answer_reaches_the_product_as_its_own_result() { + // `Unsupported` tells a product to use its own camera code, a dismissal is + // worth offering again, and a code the request does not accept never + // reaches the product, even from a buggy host. + use v01::HostScannerScanError::{CameraUnavailable, NotVisible}; + let receipt = "https://greenmarket.example/r/BAG6"; + assert_eq!(scan(&scanner_host(None)), Err(CallError::Unsupported)); + for (answer, expected) in [ + ( + scanned(receipt, v01::CodeFormat::Qr), + Ok(v01::ScanOutcome::Scanned { + text: receipt.into(), + format: v01::CodeFormat::Qr, + }), + ), + (HostScan::Dismissed, Ok(v01::ScanOutcome::Dismissed)), + ( + HostScan::CameraUnavailable, + Err(CallError::Domain(CameraUnavailable)), + ), + (HostScan::NotVisible, Err(CallError::Domain(NotVisible))), + ] { + assert_eq!( + scan(&scanner_host(Some(StubScannerPlatform::answering(answer)))), + expected + ); + } + for refused in [ + scanned("polkadotapp://pair?handshake=00", v01::CodeFormat::Qr), + scanned(receipt, v01::CodeFormat::Code128), + ] { + let host = scanner_host(Some(StubScannerPlatform::answering(refused))); + assert!(matches!( + scan(&host), + Err(CallError::Domain(v01::HostScannerScanError::Unknown { .. })) + )); + } } #[test] -fn an_invalid_request_never_reaches_the_host() { - // No viewfinder may open for a request the product could not make. +fn the_host_is_told_the_product_and_never_sees_an_invalid_request() { + // The host titles the viewfinder with the product id, and no session is + // needed. A request the product could not make never opens a viewfinder. let scanner = StubScannerPlatform::answering(HostScan::Dismissed); let host = scanner_host(Some(scanner.clone())); - let mut request = receipt_request(); - request.hint = Some("Scan the code on your computer\nto sign in".into()); - + let mut invalid = receipt_request(); + invalid.hint = Some("Scan the code on your computer\nto sign in".into()); let result = unwrap_scan(futures::executor::block_on(Scanner::scan( &host, &CallContext::default(), - HostScannerScanRequest::V1(request), + HostScannerScanRequest::V1(invalid), ))); - assert!(matches!( result, Err(CallError::Domain( v01::HostScannerScanError::InvalidRequest { .. } )) )); - assert!(scanner.asked().is_empty()); -} - -#[test] -fn a_host_answer_the_request_does_not_accept_never_reaches_the_product() { - // A buggy host must not hand a receipts product a pairing link, or the - // right text in a format it did not ask for. - for answer in [ - scanned("polkadotapp://pair?handshake=00", v01::CodeFormat::Qr), - scanned( - "https://greenmarket.example/r/BAG6", - v01::CodeFormat::Code128, - ), - ] { - let host = scanner_host(Some(StubScannerPlatform::answering(answer))); - assert!(matches!( - scan(&host), - Err(CallError::Domain(v01::HostScannerScanError::Unknown { .. })) - )); - } + assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); + assert_eq!( + scanner.asked(), + vec![("greenmarket.dot".to_owned(), receipt_request())] + ); } #[test] From 905abfab23c690cb2f7d1718894288f3ebdb4795 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Thu, 8 Oct 2026 10:10:29 -0300 Subject: [PATCH 23/29] fix(scanner): count card taps a native host publishes Native executions publish renderer actions straight to the channel they share with every connection, so the tap never reached the connection and a Worker on Android or iOS was always told NotVisible. The channel now keeps the time of its last action, which every path goes through. --- rust/crates/truapi/src/runtime.rs | 26 +++++----------------- rust/crates/truapi/src/runtime/actions.rs | 17 ++++++++++++++ rust/crates/truapi/src/runtime/tests.rs | 27 +++++++++++++++++------ 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 6977dec618..ad19b1c08c 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -42,7 +42,6 @@ mod statement_store_rpc; mod vrf; use core::future::Future; -use core::sync::atomic::{AtomicU64, Ordering}; use core::time::Duration; use std::collections::HashSet; use std::sync::{Arc, Mutex}; @@ -322,10 +321,6 @@ pub struct ProductRuntimeHost { /// operations is the host's call, made in `begin_operation`, since the /// host is what the operations keep running. open_operations: Mutex>, - /// When the user last tapped UI the host drew for this connection, in unix - /// seconds, or zero before the first tap. A Worker may scan only shortly - /// after one, because it has no screen the user could be looking at. - last_user_tap_secs: AtomicU64, } /// How long after a tap a Worker may still open the scanner, as browsers bound @@ -365,22 +360,14 @@ impl ProductRuntimeHost { pocket_platform: adapters.pocket_platform, game_platform: adapters.game_platform, open_operations: Mutex::new(HashSet::new()), - last_user_tap_secs: AtomicU64::new(0), } } - /// Record that the user tapped UI the host drew for this connection. - pub(crate) fn note_user_tap_at(&self, unix_secs: u64) { - self.last_user_tap_secs.store(unix_secs, Ordering::Release); - } - - /// Whether the user tapped UI the host drew for this connection within the - /// last [`USER_TAP_WINDOW_SECS`]. + /// Whether the user tapped UI the host drew for this product within the + /// last [`USER_TAP_WINDOW_SECS`]. A Worker may scan only shortly after one, + /// because it has no screen the user could be looking at. pub(crate) fn recently_tapped(&self) -> bool { - let tapped_at = self.last_user_tap_secs.load(Ordering::Acquire); - tapped_at != 0 - && crate::unix_time::current_unix_secs().saturating_sub(tapped_at) - <= USER_TAP_WINDOW_SECS + self.renderer.published_within(USER_TAP_WINDOW_SECS) } /// Role-neutral services shared with the owning host runtime. @@ -510,7 +497,6 @@ impl ProductRuntimeHost { pocket_platform: None, game_platform: None, open_operations: Mutex::new(HashSet::new()), - last_user_tap_secs: AtomicU64::new(0), }; (host, pairing_host) } @@ -1272,9 +1258,7 @@ impl ProductRuntimeHost { item: HostRendererActionSubscribeItem, ) -> Result<(), crate::host_core::ProductRuntimeError> { self.renderer_access()?; - self.renderer.publish(item)?; - self.note_user_tap_at(crate::unix_time::current_unix_secs()); - Ok(()) + self.renderer.publish(item) } /// Pocket access policy for this connection: the collection is reachable diff --git a/rust/crates/truapi/src/runtime/actions.rs b/rust/crates/truapi/src/runtime/actions.rs index 4e4b179db6..430ea7ff0e 100644 --- a/rust/crates/truapi/src/runtime/actions.rs +++ b/rust/crates/truapi/src/runtime/actions.rs @@ -1,5 +1,6 @@ //! Connection-scoped, host-fed action streams buffered until the product subscribes. +use core::sync::atomic::{AtomicU64, Ordering}; use std::collections::VecDeque; use std::sync::Mutex; @@ -31,6 +32,9 @@ impl Default for State { pub struct ActionChannel { state: Mutex>, closed_reason: &'static str, + /// When the host last published an item, in unix seconds, or zero before + /// the first. + last_published_secs: AtomicU64, } impl ActionChannel { @@ -40,6 +44,7 @@ impl ActionChannel { Self { state: Mutex::new(State::default()), closed_reason, + last_published_secs: AtomicU64::new(0), } } @@ -75,6 +80,7 @@ impl ActionChannel { if state.closed { return Err(ProductRuntimeError::Closed); } + self.note_published_at(crate::unix_time::current_unix_secs()); if let Some(sender) = state.subscriber.as_ref() { match sender.unbounded_send(item) { Ok(()) => return Ok(()), @@ -89,6 +95,17 @@ impl ActionChannel { Ok(()) } + /// Whether the host published an item within the last `window_secs`. + pub(crate) fn published_within(&self, window_secs: u64) -> bool { + let published_at = self.last_published_secs.load(Ordering::Acquire); + published_at != 0 + && crate::unix_time::current_unix_secs().saturating_sub(published_at) <= window_secs + } + + pub(crate) fn note_published_at(&self, unix_secs: u64) { + self.last_published_secs.store(unix_secs, Ordering::Release); + } + /// End the current subscriber's stream while keeping buffered items for /// the next product connection that subscribes. pub fn detach(&self) { diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 392280fd24..2d6d0e22ea 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7750,14 +7750,20 @@ impl crate::platform::ScannerPlatform for StubScannerPlatform { /// A runtime for `greenmarket.dot` with no session, and `scanner` installed /// when given. fn scanner_host(scanner: Option>) -> ProductRuntimeHost { - scanner_host_for(ProductExecutionKind::App, scanner) + scanner_host_for(ProductExecutionKind::App, scanner).0 } -/// [`scanner_host`] for an execution of `kind`. +/// [`scanner_host`] for an execution of `kind`, with the renderer action +/// channel its execution shares across connections. fn scanner_host_for( kind: ProductExecutionKind, scanner: Option>, -) -> ProductRuntimeHost { +) -> ( + ProductRuntimeHost, + Arc< + crate::runtime::ActionChannel, + >, +) { let (host_config, mut product) = runtime_config("greenmarket.dot"); product.execution_kind = kind; let services = RuntimeServices::with_chat_platform( @@ -7774,7 +7780,11 @@ fn scanner_host_for( } let pairing_host = PairingHost::new(services.clone(), host_config); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); - ProductRuntimeHost::from_services(services, adapters, pairing_host, product) + let renderer = adapters.renderer.clone(); + ( + ProductRuntimeHost::from_services(services, adapters, pairing_host, product), + renderer, + ) } fn receipt_request() -> truapi::latest::HostScannerScanRequest { @@ -7832,15 +7842,18 @@ fn a_worker_scans_only_shortly_after_the_user_taps_its_card() { // A Worker has no screen of its own. Without a recent tap, the viewfinder // would open over whatever the user is doing. let scanner = StubScannerPlatform::answering(HostScan::Dismissed); - let host = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); + let (host, renderer) = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); let not_visible = Err(CallError::Domain(v01::HostScannerScanError::NotVisible)); assert_eq!(scan(&host), not_visible); - host.note_user_tap_at(crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1); + renderer + .note_published_at(crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1); assert_eq!(scan(&host), not_visible); assert!(scanner.asked().is_empty()); - host.publish_renderer_action(card_tap()).unwrap(); + // Native executions publish taps straight to the channel they share with + // every connection, not through the connection. + renderer.publish(card_tap()).unwrap(); assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); } From 6e01ca5aa0eef9556cf682b0fde68d1a18345490 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Thu, 8 Oct 2026 10:40:03 -0300 Subject: [PATCH 24/29] refactor(scanner): use plain pub for the scanner runtime items --- rust/crates/truapi/src/runtime.rs | 4 ++-- rust/crates/truapi/src/runtime/actions.rs | 5 +++-- rust/crates/truapi/src/runtime/services.rs | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index ad19b1c08c..89bae75556 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -325,7 +325,7 @@ pub struct ProductRuntimeHost { /// How long after a tap a Worker may still open the scanner, as browsers bound /// transient user activation. -pub(crate) const USER_TAP_WINDOW_SECS: u64 = 5; +pub const USER_TAP_WINDOW_SECS: u64 = 5; /// A connection that goes away without ending its operations still owes the /// ledger their references, so the host is told to stop rather than keeping a @@ -366,7 +366,7 @@ impl ProductRuntimeHost { /// Whether the user tapped UI the host drew for this product within the /// last [`USER_TAP_WINDOW_SECS`]. A Worker may scan only shortly after one, /// because it has no screen the user could be looking at. - pub(crate) fn recently_tapped(&self) -> bool { + pub fn recently_tapped(&self) -> bool { self.renderer.published_within(USER_TAP_WINDOW_SECS) } diff --git a/rust/crates/truapi/src/runtime/actions.rs b/rust/crates/truapi/src/runtime/actions.rs index 430ea7ff0e..de31fef4a9 100644 --- a/rust/crates/truapi/src/runtime/actions.rs +++ b/rust/crates/truapi/src/runtime/actions.rs @@ -96,13 +96,14 @@ impl ActionChannel { } /// Whether the host published an item within the last `window_secs`. - pub(crate) fn published_within(&self, window_secs: u64) -> bool { + pub fn published_within(&self, window_secs: u64) -> bool { let published_at = self.last_published_secs.load(Ordering::Acquire); published_at != 0 && crate::unix_time::current_unix_secs().saturating_sub(published_at) <= window_secs } - pub(crate) fn note_published_at(&self, unix_secs: u64) { + /// Record that the host published an item at `unix_secs`. + pub fn note_published_at(&self, unix_secs: u64) { self.last_published_secs.store(unix_secs, Ordering::Release); } diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index c89d8c621e..febca09435 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -274,7 +274,7 @@ impl RuntimeServices { /// Claim the one open scan, or `None` while another holds it. Dropping the /// claim frees it, so a cancelled or failed scan cannot keep it. - pub(crate) fn claim_scan(&self) -> Option> { + pub fn claim_scan(&self) -> Option> { self.scan_open .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .ok() @@ -508,7 +508,7 @@ impl RuntimeChainProvider for HostChainProvider { } /// The open scan. Frees the slot when dropped. -pub(crate) struct ScanClaim<'a>(&'a AtomicBool); +pub struct ScanClaim<'a>(&'a AtomicBool); impl Drop for ScanClaim<'_> { fn drop(&mut self) { From b4157ea06135189c4b4d85adbc72352a66ecec0d Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Thu, 8 Oct 2026 12:02:08 -0300 Subject: [PATCH 25/29] fix(scanner): count only delivered taps and test the native path A renderer action the channel refuses no longer opens a scan window. A native test publishes a tap on the execution and checks it on the connection, which is the path Android and iOS use. The backdating setter is test-only. --- rust/crates/truapi/src/native/runtime.rs | 34 +++++++++++++++++++++++ rust/crates/truapi/src/runtime.rs | 6 ++-- rust/crates/truapi/src/runtime/actions.rs | 13 +++++++-- rust/crates/truapi/src/runtime/tests.rs | 26 +++++------------ 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 4b9e254d24..ef1cb79372 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -1183,6 +1183,40 @@ mod tests { assert_eq!(delivered, published); } + #[test] + fn a_tap_the_execution_publishes_lets_its_worker_scan() { + // Hosts publish taps on the execution, while the scanner checks them on + // the connection the scan arrives on. + 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, + None, + None, + native_execution_config("receipts.dot", ProductExecutionKind::Worker), + ) + .expect("Worker execution should open"); + let admin = execution.admin(); + let connection = admin.product_runtime(); + assert!(!connection.recently_tapped()); + + execution + .publish_renderer_action(v01::HostRendererActionSubscribeItem { + context: v01::RenderContext::PocketCard { + card_id: "receipts".to_string(), + }, + action_id: "scan".to_string(), + payload: Vec::new(), + }) + .expect("a Worker execution may publish renderer actions"); + assert!(connection.recently_tapped()); + } + #[test] fn product_execution_routes_chain_events_to_shared_and_scoped_services() { let host = NativeTrUApiHostRuntime::with_runtime_config( diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 89bae75556..c25928bf46 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -363,9 +363,9 @@ impl ProductRuntimeHost { } } - /// Whether the user tapped UI the host drew for this product within the - /// last [`USER_TAP_WINDOW_SECS`]. A Worker may scan only shortly after one, - /// because it has no screen the user could be looking at. + /// Whether the host published a renderer action, which only the user + /// causes, within the last [`USER_TAP_WINDOW_SECS`]. A Worker may scan only + /// shortly after one, because it has no screen the user could be looking at. pub fn recently_tapped(&self) -> bool { self.renderer.published_within(USER_TAP_WINDOW_SECS) } diff --git a/rust/crates/truapi/src/runtime/actions.rs b/rust/crates/truapi/src/runtime/actions.rs index de31fef4a9..a062e9999c 100644 --- a/rust/crates/truapi/src/runtime/actions.rs +++ b/rust/crates/truapi/src/runtime/actions.rs @@ -75,12 +75,18 @@ impl ActionChannel { } /// Publish one item, buffering it until the product subscribes. - pub fn publish(&self, mut item: Item) -> Result<(), ProductRuntimeError> { + pub fn publish(&self, item: Item) -> Result<(), ProductRuntimeError> { + self.deliver(item)?; + self.last_published_secs + .store(crate::unix_time::current_unix_secs(), Ordering::Release); + Ok(()) + } + + fn deliver(&self, mut item: Item) -> Result<(), ProductRuntimeError> { let mut state = self.state.lock().expect("action channel mutex poisoned"); if state.closed { return Err(ProductRuntimeError::Closed); } - self.note_published_at(crate::unix_time::current_unix_secs()); if let Some(sender) = state.subscriber.as_ref() { match sender.unbounded_send(item) { Ok(()) => return Ok(()), @@ -102,7 +108,8 @@ impl ActionChannel { && crate::unix_time::current_unix_secs().saturating_sub(published_at) <= window_secs } - /// Record that the host published an item at `unix_secs`. + /// Backdate the last publish to `unix_secs`. + #[cfg(test)] pub fn note_published_at(&self, unix_secs: u64) { self.last_published_secs.store(unix_secs, Ordering::Release); } diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 2d6d0e22ea..a201a73209 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7750,20 +7750,14 @@ impl crate::platform::ScannerPlatform for StubScannerPlatform { /// A runtime for `greenmarket.dot` with no session, and `scanner` installed /// when given. fn scanner_host(scanner: Option>) -> ProductRuntimeHost { - scanner_host_for(ProductExecutionKind::App, scanner).0 + scanner_host_for(ProductExecutionKind::App, scanner) } -/// [`scanner_host`] for an execution of `kind`, with the renderer action -/// channel its execution shares across connections. +/// [`scanner_host`] for an execution of `kind`. fn scanner_host_for( kind: ProductExecutionKind, scanner: Option>, -) -> ( - ProductRuntimeHost, - Arc< - crate::runtime::ActionChannel, - >, -) { +) -> ProductRuntimeHost { let (host_config, mut product) = runtime_config("greenmarket.dot"); product.execution_kind = kind; let services = RuntimeServices::with_chat_platform( @@ -7780,11 +7774,7 @@ fn scanner_host_for( } let pairing_host = PairingHost::new(services.clone(), host_config); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); - let renderer = adapters.renderer.clone(); - ( - ProductRuntimeHost::from_services(services, adapters, pairing_host, product), - renderer, - ) + ProductRuntimeHost::from_services(services, adapters, pairing_host, product) } fn receipt_request() -> truapi::latest::HostScannerScanRequest { @@ -7842,18 +7832,16 @@ fn a_worker_scans_only_shortly_after_the_user_taps_its_card() { // A Worker has no screen of its own. Without a recent tap, the viewfinder // would open over whatever the user is doing. let scanner = StubScannerPlatform::answering(HostScan::Dismissed); - let (host, renderer) = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); + let host = scanner_host_for(ProductExecutionKind::Worker, Some(scanner.clone())); let not_visible = Err(CallError::Domain(v01::HostScannerScanError::NotVisible)); assert_eq!(scan(&host), not_visible); - renderer + host.renderer .note_published_at(crate::unix_time::current_unix_secs() - super::USER_TAP_WINDOW_SECS - 1); assert_eq!(scan(&host), not_visible); assert!(scanner.asked().is_empty()); - // Native executions publish taps straight to the channel they share with - // every connection, not through the connection. - renderer.publish(card_tap()).unwrap(); + host.publish_renderer_action(card_tap()).unwrap(); assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); } From 36e35fbf6c92a300f2b63c236e675b248dc9cc39 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Thu, 8 Oct 2026 12:07:04 -0300 Subject: [PATCH 26/29] fix(scanner): stamp a tap before the product can react to it The tap time is written before the action reaches a live subscriber, so a Worker that scans as soon as it hears the tap finds it. A refused action still does not count, and a test pins that. --- rust/crates/truapi/src/native/runtime.rs | 4 ++-- rust/crates/truapi/src/runtime/actions.rs | 27 ++++++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index ef1cb79372..984b4c7e77 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -1184,9 +1184,9 @@ mod tests { } #[test] - fn a_tap_the_execution_publishes_lets_its_worker_scan() { + fn a_tap_the_execution_publishes_counts_on_its_connections() { // Hosts publish taps on the execution, while the scanner checks them on - // the connection the scan arrives on. + // a connection the execution opened. let host = NativeTrUApiHostRuntime::with_runtime_config( Arc::new(EventCallbacks::new()), native_host_runtime_config(), diff --git a/rust/crates/truapi/src/runtime/actions.rs b/rust/crates/truapi/src/runtime/actions.rs index a062e9999c..a6a5c7f8a2 100644 --- a/rust/crates/truapi/src/runtime/actions.rs +++ b/rust/crates/truapi/src/runtime/actions.rs @@ -75,19 +75,14 @@ impl ActionChannel { } /// Publish one item, buffering it until the product subscribes. - pub fn publish(&self, item: Item) -> Result<(), ProductRuntimeError> { - self.deliver(item)?; - self.last_published_secs - .store(crate::unix_time::current_unix_secs(), Ordering::Release); - Ok(()) - } - - fn deliver(&self, mut item: Item) -> Result<(), ProductRuntimeError> { + pub 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() { + // Stamped first, so a product reacting to the item already finds it. + self.note_published_now(); match sender.unbounded_send(item) { Ok(()) => return Ok(()), Err(error) => item = error.into_inner(), @@ -98,9 +93,15 @@ impl ActionChannel { return Err(ProductRuntimeError::BufferFull); } state.buffer.push_back(item); + self.note_published_now(); Ok(()) } + fn note_published_now(&self) { + self.last_published_secs + .store(crate::unix_time::current_unix_secs(), Ordering::Release); + } + /// Whether the host published an item within the last `window_secs`. pub fn published_within(&self, window_secs: u64) -> bool { let published_at = self.last_published_secs.load(Ordering::Acquire); @@ -152,6 +153,16 @@ mod tests { assert_eq!(block_on(items.next()), Some(Ok("second".to_string()))); } + #[test] + fn a_refused_action_is_not_a_tap() { + // A Worker may scan only after a tap it can receive. + let channel = channel(); + channel.close(); + + assert!(channel.publish("tap".to_string()).is_err()); + assert!(!channel.published_within(5)); + } + #[test] fn full_startup_action_buffer_is_reported() { let channel = channel(); From 9b2767ace9ce8f20836994526269f1605c91f702 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Thu, 8 Oct 2026 12:56:02 -0300 Subject: [PATCH 27/29] test(scanner): fold the native tap check into the renderer action test The tap check reuses the native renderer action test's setup instead of repeating it. The hint test keeps one refused character per rule, a dead error mapping goes, and an unrelated test keeps main's formatting. --- rust/crates/truapi/src/host_logic/scanner.rs | 3 -- rust/crates/truapi/src/native/runtime.rs | 36 ++------------------ rust/crates/truapi/src/runtime/tests.rs | 9 +++-- 3 files changed, 8 insertions(+), 40 deletions(-) diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index cdf01997a9..eb19c04593 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -182,15 +182,12 @@ mod tests { assert!(!valid(qr, None, Some(&"a".repeat(MAX_HINT_CHARS + 1)))); for refused in [ "scan\nto sign in", - "tab\there", "line\u{2028}separator", "para\u{2029}separator", "\u{202E}reversed", "\u{2066}isolate", "\u{200F}mark", "\u{061C}arabic letter mark", - "zero\u{200B}width", - "word\u{2060}joiner", "\u{FEFF}byte order mark", "\u{FFF9}annotation", ] { diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 984b4c7e77..06b7fe1890 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -1181,40 +1181,8 @@ mod tests { panic!("expected a renderer action item") }; assert_eq!(delivered, published); - } - - #[test] - fn a_tap_the_execution_publishes_counts_on_its_connections() { - // Hosts publish taps on the execution, while the scanner checks them on - // a connection the execution opened. - 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, - None, - None, - native_execution_config("receipts.dot", ProductExecutionKind::Worker), - ) - .expect("Worker execution should open"); - let admin = execution.admin(); - let connection = admin.product_runtime(); - assert!(!connection.recently_tapped()); - - execution - .publish_renderer_action(v01::HostRendererActionSubscribeItem { - context: v01::RenderContext::PocketCard { - card_id: "receipts".to_string(), - }, - action_id: "scan".to_string(), - payload: Vec::new(), - }) - .expect("a Worker execution may publish renderer actions"); - assert!(connection.recently_tapped()); + // The scanner checks taps on a connection, while hosts publish them here. + assert!(admin.product_runtime().recently_tapped()); } #[test] diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 868ac0b416..797f45a09a 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -7810,7 +7810,6 @@ fn unwrap_scan( Ok(HostScannerScanResponse::V1(response)) => Ok(response.outcome), Err(CallError::Domain(HostScannerScanError::V1(error))) => Err(CallError::Domain(error)), Err(CallError::Unsupported) => Err(CallError::Unsupported), - Err(CallError::Cancelled) => Err(CallError::Cancelled), Err(other) => panic!("unexpected scanner error: {other:?}"), } } @@ -8036,8 +8035,12 @@ fn a_pairing_test_host_keeps_a_submitted_preimage_and_serves_it_back() { let session = sso_session_info(); let platform = Arc::new(StubPlatform::default()); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = - ProductRuntimeHost::new_pairing_for_tests(platform, host_config, product, test_spawner()); + let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + platform, + host_config, + product, + test_spawner(), + ); install_pairing_session(&host, session.clone()); let lifecycle_epoch = pairing_host.current_session_lifecycle_epoch(); futures::executor::block_on(pairing_host.cache_bulletin_allowance_key( From f4a343f814cd5455c05873a8b3b898c976f4909a Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Thu, 8 Oct 2026 14:43:17 -0300 Subject: [PATCH 28/29] test(scanner): drop the CLI scanner phase, which repeated the core tests --- Makefile | 5 +- README.md | 6 --- .../js/scripts/scanner-battery.ts | 51 ------------------- scripts/battery.sh | 44 +--------------- 4 files changed, 2 insertions(+), 104 deletions(-) delete mode 100644 rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts diff --git a/Makefile b/Makefile index c9278cdfb9..4ee1897e15 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap debugger dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-pocket-cli e2e-scanner-cli e2e-cross-product-storage e2e-cross-product-ringvrf e2e-cross-product-signing e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework +.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap debugger dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-pocket-cli e2e-cross-product-storage e2e-cross-product-ringvrf e2e-cross-product-signing e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework CARGO ?= cargo # The dated nightly CI runs; see nightly-toolchain. @@ -464,9 +464,6 @@ e2e-chat-cli: ## Run the Chat content-screening battery against a chat signing-h e2e-pocket-cli: ## Run the Pocket protocol battery against a Pocket signing-host CLI. scripts/battery.sh --pocket-host -e2e-scanner-cli: ## Run the scanner checks against both CLI host roles, each scanning a fixed code. - scripts/battery.sh --scanner-host - e2e-cross-product-storage: ## One product reads another's storage on the signing-host CLI, granted by a local product config. scripts/cross-product-storage-e2e.sh diff --git a/README.md b/README.md index 8631e15605..1bae7b8c19 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,6 @@ make e2e-signing-cli # same direct signing-host phase make e2e-pairing-cli # same paired pairing-host phase make e2e-chat-cli # chat content screening against a chat signing-host make e2e-pocket-cli # Pocket protocol check against a Pocket signing-host -make e2e-scanner-cli # scanner checks against both CLI host roles, scanning a fixed code ``` The Pocket phase runs its product as a Worker execution, the only execution @@ -332,11 +331,6 @@ host failures. "Host-drawn scanner"). Both CLI host roles answer it from `TRUAPI_SCAN_TEXT`, or with a dismissal when it is unset, so the battery runs it. The playground's Diagnosis skips it, since dot.li has no viewfinder and answers `Unsupported`. -`make e2e-scanner-cli` runs the scanner phase on both CLI host roles, the -pairing host unpaired since scanning needs no session. The host scans -`https://greenmarket.example/r/BAG6`, and the cases check that a matching code -reaches the product, that a code the product did not ask for never -does, and that an invalid request is refused before the host is asked. To run the playground locally in a plain browser tab, against a signing host on your own machine: diff --git a/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts b/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts deleted file mode 100644 index 2d33f4f2c5..0000000000 --- a/rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts +++ /dev/null @@ -1,51 +0,0 @@ -/// -// Scanner checks against a real host, run by `scripts/battery.sh --scanner-host`. -// The host scans SCANNED_TEXT in every case and does not filter, so a refused -// code proves the core checked the host's answer. The product never signs in. -import type { HostScannerScanRequest } from "../../../../../js/packages/truapi/src/generated/types.ts"; - -const SCANNED_TEXT = "https://greenmarket.example/r/BAG6"; - -if (process.env.TRUAPI_SCAN_TEXT !== SCANNED_TEXT) { - throw new Error(`scanner battery needs TRUAPI_SCAN_TEXT=${SCANNED_TEXT}`); -} - -const SCANNED = { tag: "Scanned", value: { text: SCANNED_TEXT, format: "Qr" } }; - -const cases: [string, HostScannerScanRequest, string | typeof SCANNED][] = [ - [ - "a matching code reaches the product", - { formats: ["Qr"], prefix: "https://greenmarket.example/r/" }, - SCANNED, - ], - [ - "a code without the prefix is refused", - { formats: ["Qr"], prefix: "polkadotapp://pair" }, - "Unknown", - ], - [ - "a two-line hint is refused before the host is asked", - { formats: ["Qr"], hint: "Scan this\nto sign in" }, - "InvalidRequest", - ], -]; - -let failures = 0; -for (const [name, request, expected] of cases) { - const result = await truapi.scanner.scan(request); - const actual = result.isOk() - ? result.value.outcome - : result.error.tag === "Domain" - ? result.error.value.value.tag - : result.error.tag; - const pass = JSON.stringify(actual) === JSON.stringify(expected); - if (!pass) failures++; - console.log(`${pass ? "✅" : "❌"} ${name}: ${JSON.stringify(actual)}`); -} - -if (failures > 0) { - throw new Error( - `scanner battery failed: ${failures} of ${cases.length} cases`, - ); -} -console.log(`scanner battery: ${cases.length} cases passed`); diff --git a/scripts/battery.sh b/scripts/battery.sh index a5f5155a10..e081b77ff8 100755 --- a/scripts/battery.sh +++ b/scripts/battery.sh @@ -11,7 +11,6 @@ # scripts/battery.sh --signing-host # direct phase only # scripts/battery.sh --pairing-host # paired phase only # scripts/battery.sh --pocket-host # Pocket phase only -# scripts/battery.sh --scanner-host # scanner phase only # scripts/battery.sh --release # build and run the release binary # scripts/battery.sh -- --network foo # arguments after `--` go to every host process # @@ -49,7 +48,6 @@ unset DYLD_LIBRARY_PATH SCRIPT="rust/crates/truapi-host-cli/js/scripts/battery.ts" CHAT_SCRIPT="rust/crates/truapi-host-cli/js/scripts/chat-battery.ts" POCKET_SCRIPT="rust/crates/truapi-host-cli/js/scripts/pocket-battery.ts" -SCANNER_SCRIPT="rust/crates/truapi-host-cli/js/scripts/scanner-battery.ts" PRODUCT_ID="truapi-playground.dot" REPORTS="explorer/diagnosis-reports/spa" LOG_DIR="target/battery" @@ -63,7 +61,6 @@ RUN_SIGNING=1 RUN_PAIRING=1 RUN_CHAT=0 RUN_POCKET=0 -RUN_SCANNER=0 usage() { awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" @@ -79,9 +76,6 @@ while [ $# -gt 0 ]; do # Pocket is its own phase for the same reason: it needs a Worker-execution # connection with a seeded card set, which no other phase opens. --pocket-host) RUN_SIGNING=0; RUN_PAIRING=0; RUN_POCKET=1 ;; - # The scanner is its own phase because the host needs TRUAPI_SCAN_TEXT set, - # and the generated battery expects a dismissal from a host without it. - --scanner-host) RUN_SIGNING=0; RUN_PAIRING=0; RUN_SCANNER=1 ;; --release) CARGO_ARGS+=(--release); PROFILE_DIR="release" ;; --product-id) [ $# -ge 2 ] || { echo "battery: --product-id needs a value" >&2; exit 2; } @@ -248,35 +242,6 @@ pocket_phase() { return "$rc" } -scanner_phase() { - echo "battery: scanner phase" - # The code the CLI scans in every case. The cases refuse it on purpose too, - # which only the core can do, since the CLI does not filter. - export TRUAPI_SCAN_TEXT="https://greenmarket.example/r/BAG6" - local role rc=0 - # Scanning needs no session, so the pairing host runs unpaired, from a fresh - # base path so no earlier session is restored. - for role in signing-host pairing-host; do - local log="$LOG_DIR/scanner-$role-cli.log" role_args=() - if [ "$role" = pairing-host ]; then - rm -rf "$LOG_DIR/scanner-pairing-host-state" - role_args=(--base-path "$LOG_DIR/scanner-pairing-host-state") - fi - "$HOST" "$role" \ - --product-id "$PRODUCT_ID" \ - --script "$SCANNER_SCRIPT" \ - --auto-accept \ - ${role_args[@]+"${role_args[@]}"} \ - ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 & - local host_pid=$! - start_watchdog "$host_pid" "scanner phase ($role)" - wait "$host_pid" || rc=$? - stop_watchdog - done - unset TRUAPI_SCAN_TEXT - return "$rc" -} - pairing_phase() { local log="$LOG_DIR/pairing-host-cli.log" local signer_log="$LOG_DIR/pairing-host-cli-signer.log" @@ -351,7 +316,6 @@ SIGNING_RC="skipped" PAIRING_RC="skipped" CHAT_RC="skipped" POCKET_RC="skipped" -SCANNER_RC="skipped" # Each phase writes under the directory for the modality it exercises, so the # summary names the ones this run actually produced rather than always the App @@ -390,16 +354,10 @@ if [ "$RUN_POCKET" = 1 ]; then pocket_phase || POCKET_RC=$? fi -if [ "$RUN_SCANNER" = 1 ]; then - SCANNER_RC=0 - scanner_phase || SCANNER_RC=$? -fi - echo -echo "battery: signing-host exit=$SIGNING_RC · pairing-host exit=$PAIRING_RC · chat-host exit=$CHAT_RC · pocket-host exit=$POCKET_RC · scanner-host exit=$SCANNER_RC" +echo "battery: signing-host exit=$SIGNING_RC · pairing-host exit=$PAIRING_RC · chat-host exit=$CHAT_RC · pocket-host exit=$POCKET_RC" echo "battery: reports under $(report_dirs), logs under $LOG_DIR/" [ "$SIGNING_RC" = 0 ] || [ "$SIGNING_RC" = "skipped" ] || exit "$SIGNING_RC" [ "$PAIRING_RC" = 0 ] || [ "$PAIRING_RC" = "skipped" ] || exit "$PAIRING_RC" [ "$CHAT_RC" = 0 ] || [ "$CHAT_RC" = "skipped" ] || exit "$CHAT_RC" [ "$POCKET_RC" = 0 ] || [ "$POCKET_RC" = "skipped" ] || exit "$POCKET_RC" -[ "$SCANNER_RC" = 0 ] || [ "$SCANNER_RC" = "skipped" ] || exit "$SCANNER_RC" From 87b3e9ef747c8aecbcc8de2ba7e495e74331ef50 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Thu, 8 Oct 2026 15:48:42 -0300 Subject: [PATCH 29/29] fix(scanner): refuse a pairing handshake in any form a wallet reads The check went through the core's own decoder, which reads only a bare V2 proposal after the first `?handshake=`. Wallets also read `0x` hex, V1 proposals, and a handshake among other query items, so those reached the product. The check now goes by shape: any `handshake=` value or the bare text, in hex with or without `0x`, starting with a known version tag and long enough for the device keys. --- docs/rfcs/host-scanner.md | 6 ++- rust/crates/truapi/src/host_logic/scanner.rs | 57 +++++++++++++++++--- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md index 25d72937b4..7272a3ace1 100644 --- a/docs/rfcs/host-scanner.md +++ b/docs/rfcs/host-scanner.md @@ -121,8 +121,10 @@ of that and returns the text. If the product wants to follow a scanned link, it rules. **A pairing request never reaches a product.** A pairing link lets whoever answers it first pair with the device that -showed it, so it is a credential. The core refuses any code it would itself accept as a pairing request, whatever the -request's formats and prefix, including the bare handshake without the `pair` link around it. In the viewfinder it is +showed it, so it is a credential. The core refuses any code that carries a handshake in a form some wallet reads, +whatever the request's formats and prefix: any `handshake=` value or the bare handshake, in hex with or without `0x`, +of any proposal version. It matches the shape rather than decoding, so a wallet more lenient than the core is still +covered. In the viewfinder it is treated like any code that is not for this product. The prefix stays optional, because a barcode such as an EAN-13 grocery code has no prefix to give, and a required prefix would not help anyway: a product could pass `polkadotapp://pair?` as its prefix. diff --git a/rust/crates/truapi/src/host_logic/scanner.rs b/rust/crates/truapi/src/host_logic/scanner.rs index eb19c04593..d8fe2d4749 100644 --- a/rust/crates/truapi/src/host_logic/scanner.rs +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -73,10 +73,36 @@ pub fn accepts(request: &HostScannerScanRequest, format: CodeFormat, text: &str) }) } -/// Whether the core would accept `text` as a pairing request, with or without -/// the `pair` link around the handshake. +/// Proposal version tags wallets read: V1, which the core no longer sends, and +/// V2. +const PROPOSAL_VERSIONS: [u8; 2] = [0, 1]; +/// A version tag followed by the host's two 32-byte device keys, the start of +/// every proposal version. +const MIN_PROPOSAL_BYTES: usize = 1 + 32 + 32; + +/// Whether `text` carries a pairing handshake in a form some wallet reads: as +/// any `handshake=` value or bare, in hex with or without `0x`. Matched by +/// shape rather than decoded, so a wallet more lenient than the core still +/// never receives one through a product. fn is_pairing_request(text: &str) -> bool { - crate::host_logic::sso::pairing::decode_pairing_deeplink(text).is_ok() + let handshake_values = text + .split("handshake=") + .skip(1) + .map(|rest| rest.split(['&', '#']).next().unwrap_or_default()); + core::iter::once(text) + .chain(handshake_values) + .any(is_handshake_proposal) +} + +fn is_handshake_proposal(payload: &str) -> bool { + let payload = payload.trim(); + let hex = payload + .strip_prefix("0x") + .or_else(|| payload.strip_prefix("0X")) + .unwrap_or(payload); + hex::decode(hex).is_ok_and(|bytes| { + bytes.len() >= MIN_PROPOSAL_BYTES && PROPOSAL_VERSIONS.contains(&bytes[0]) + }) } /// What a host does with one code the camera read. @@ -231,14 +257,31 @@ mod tests { [2; 32], &config, ); - let other_scheme = link.replacen("polkadotapp", "otherwallet", 1); - let bare_handshake = link.split_once("?handshake=").unwrap().1.to_owned(); - for text in [&link, &other_scheme, &bare_handshake] { + let handshake = link.split_once("?handshake=").unwrap().1; + // Wallets read these forms too, and a V1 proposal starts with tag 0. + let v1_handshake = format!("00{}", "07".repeat(70)); + for text in [ + link.clone(), + link.replacen("polkadotapp", "otherwallet", 1), + link.replacen("?handshake=", "?handshake=0x", 1), + link.replacen("?handshake=", "?source=poster&handshake=", 1), + format!("{link}&source=poster"), + format!("polkadotapp://pair?handshake={v1_handshake}"), + handshake.to_owned(), + format!("0x{handshake}"), + ] { for prefix in [None, Some("polkadotapp://pair?")] { let any_qr = request(&[CodeFormat::Qr], prefix, None); - assert!(!accepts(&any_qr, CodeFormat::Qr, text), "{text}"); + assert!(!accepts(&any_qr, CodeFormat::Qr, &text), "{text}"); } } + // Too short to hold a proposal's keys, so a product may receive it. + let account = format!("0x{}", "01".repeat(32)); + assert!(accepts( + &request(&[CodeFormat::Qr], None, None), + CodeFormat::Qr, + &account + )); } #[test]