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/.changeset/host-scanner.md b/.changeset/host-scanner.md new file mode 100644 index 0000000000..e6d89abfc3 --- /dev/null +++ b/.changeset/host-scanner.md @@ -0,0 +1,6 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +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/README.md b/README.md index 94b6a40b57..1bae7b8c19 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/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index ea7dba898c..3c171fd3f9 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 @@ -90,6 +90,10 @@ 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 +import uniffi.truapi.ProductExecutionKind /** Package metadata. */ object TrUAPIHost { @@ -684,13 +688,28 @@ 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) } +} + +/** + * 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) + suspend fun scanCode( + productId: String, + executionKind: ProductExecutionKind, + request: HostScannerScanRequest, + ): HostScan +} + +private class ScannerCallbackAdapter(private val bridge: ScannerHostBridge) : NativeScannerCallbacks { + 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 { @@ -754,6 +773,19 @@ 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 before opening any product execution. + * Set-once: returns whether this call installed it. + */ + 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/docs/rfcs/host-scanner.md b/docs/rfcs/host-scanner.md index f7e4c03ef6..7272a3ace1 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, @@ -81,7 +81,7 @@ pub enum HostScannerScanError { 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. + /// No formats, a format named twice, or a prefix or hint that breaks its limit. InvalidRequest { reason: String }, Unknown { reason: String }, } @@ -112,7 +112,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 @@ -120,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/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 9d767448f7..cfdbc5f952 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -310,6 +310,16 @@ public protocol ContactsHostBridge: AnyObject, Sendable { func pickContact(productId: String) async throws -> HostContactPick } +/// 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, + executionKind: ProductExecutionKind, + 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) {} @@ -409,16 +419,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 @@ -437,16 +437,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 @@ -465,16 +455,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 @@ -487,22 +467,30 @@ 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) } + } +} + +/// 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, + executionKind: ProductExecutionKind, + request: HostScannerScanRequest + ) async throws -> HostScan { + try await withHostRejection { + try await bridge.scanCode(productId: productId, executionKind: executionKind, request: request) } } } @@ -697,26 +685,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() @@ -756,6 +724,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) @@ -804,6 +773,15 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { return inner.setContactsCallbacks(callbacks: adapter) } + /// 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) + 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. @@ -1185,6 +1163,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, diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index e513243959..cda5200b91 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,11 @@ 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. 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 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..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,6 +58,32 @@ suite("createMockClient", () => { } }); + 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 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", + }); + + const unserved = await bare.client.scanner.scan({ formats: ["Qr"] }); + expect(unserved._unsafeUnwrapErr().tag).toBe("Unsupported"); + expect(() => bare.host.setScanAnswer({ tag: "Dismissed" })).toThrow(); + } finally { + served.dispose(); + bare.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 @@ -108,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(), @@ -120,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/js/packages/truapi-host/src/testing/playwright.ts b/js/packages/truapi-host/src/testing/playwright.ts index 6ac3597f23..212b57fdab 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,8 @@ export interface TestHost { clearPreimages(): Promise; getTheme(): Promise; setTheme(variant: string): Promise; + /** Set the next scan's answer. Throws unless created with a `scanner` answer. */ + setScanAnswer(answer: HostScan): Promise; getIsAuthenticated(): Promise; getChainStatus(): Promise; getConnectionStatus(): Promise; @@ -754,6 +757,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/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index 06b15bcc61..087ee74dad 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,8 @@ export interface MockHostConfig { languageTag?: string; /** Whether `confirmUserAction` confirms reviewed actions. Default `true`. */ confirmUserActions?: boolean; + /** 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 * (the default) means a silent connection: it records outbound requests and @@ -550,6 +553,8 @@ export interface MockHost { getTheme(): ThemeVariant; /** Replace the reported theme. */ setTheme(variant: ThemeVariant): void; + /** 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; /** Mark the chain disconnected, as a dropped transport would. */ @@ -845,6 +850,7 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { chainProxies = [], languageTag = "en", faults = {}, + scanner, supportedChains = { network: "mock", chains: [ @@ -855,6 +861,7 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { }, } = config; + const scan = scanner === undefined ? undefined : { answer: scanner }; const storage = new Map(); const preimages = new Map(); const navigations: string[] = []; @@ -1166,6 +1173,16 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { async cancelGameReminder() {}, }, + ...(scan === undefined + ? {} + : { + scanner: { + async scanCode() { + return scan.answer; + }, + }, + }), + permissions: { async devicePermission(_product, request) { if (faults.permissionError) throw new Error(faults.permissionError); @@ -1494,6 +1511,14 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { enforcePermissions = enforce; }, getTheme: () => currentTheme, + setScanAnswer: (answer) => { + if (scan === undefined) { + throw new Error( + "this mock serves no scanner: create it with a `scanner` answer", + ); + } + scan.answer = 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 8f7afc88d4..4aa389980c 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 @@ -1271,6 +1271,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-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 6d9a8d3283..c8deea9747 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, @@ -361,6 +363,30 @@ 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 } + /** + * 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. */ @@ -885,6 +911,22 @@ 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, + NotVisible: S._void, + }), +); + /** * Review shown before a product learns the user's primary identity. */ @@ -1697,6 +1739,29 @@ export interface ProductStorage { ): AsyncIterable>; } +/** + * Host-owned viewfinder for QR codes and barcodes. Optional. The Swift and + * Kotlin bridges and the JS `scanner` callbacks follow the same rules. + * + * - 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 { + /** + * Open the viewfinder on behalf of `product` and wait for the user. + */ + scanCode( + product: ProductContext, + request: HostScannerScanRequest, + ): Promise; +} + /** * Host theme source. */ @@ -1753,6 +1818,7 @@ export interface HostCallbacks { game?: GamePlatform; permissionStatus?: PermissionStatusHost; pocket?: PocketPlatform; + scanner?: ScannerPlatform; } export interface RequiredHostCallbacks { @@ -1774,4 +1840,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-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/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..ca427f14ac --- /dev/null +++ b/rust/crates/truapi-host-cli/src/scanner.rs @@ -0,0 +1,27 @@ +//! 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}; + +/// 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 = 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) + } +} diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 13ac95036e..4cc09b73eb 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -416,19 +416,25 @@ 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. 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`, -`PocketPlatform` and `GamePlatform`, which `OptionalPlatform` lists instead: a -host supplies each only when it can serve it. `ExpandedCardHost` is in neither, -because it travels per connection rather than with the platform. Codegen reads -`OptionalPlatform` to emit each listed capability as an optional group on the -host-callback surface. +`PocketPlatform`, `GamePlatform` and `ScannerPlatform`, which `OptionalPlatform` +lists instead: a host supplies each only when it can serve it. `ExpandedCardHost` +is in neither, because it travels per connection rather than with the platform. +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. A connection without an `ExpandedCardHost` -answers a Widget's `ExpandedCard` calls `Unsupported`. +omitting `ContactsPlatform`, `PocketPlatform`, `GamePlatform` or +`ScannerPlatform` does the same for Contacts, Pocket, Game or Scanner calls. A +connection without an `ExpandedCardHost` answers a Widget's `ExpandedCard` calls +`Unsupported`. 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 diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 08b8596aec..41e15c0fa1 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -16,7 +16,7 @@ use std::time::Duration; use crate::platform::{ ChatPlatform, ContactsPlatform, ExpandedCardHost, GamePlatform, PermissionStatusHost, - PocketPlatform, + PocketPlatform, ScannerPlatform, }; use crate::platform::{ CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, @@ -287,6 +287,13 @@ impl PairingHostRuntime { self.services.install_game_platform(platform) } + /// 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) + } + /// Install the host's [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// @@ -728,6 +735,13 @@ impl SigningHostRuntime { self.services.install_game_platform(platform) } + /// 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) + } + /// Install the host's [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// 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..d8fe2d4749 --- /dev/null +++ b/rust/crates/truapi/src/host_logic/scanner.rs @@ -0,0 +1,311 @@ +//! 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::collections::BTreeSet; +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 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 + { + 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, flip its +/// direction, or hide text under the host's title. +fn breaks_the_line(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{2028}' + | '\u{2029}' + | '\u{061C}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{2069}' + | '\u{FEFF}' + | '\u{FFF9}'..='\u{FFFB}' + ) +} + +/// 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 { + !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)) + }) +} + +/// 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 { + 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. +#[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, + refused: BTreeSet<(u8, 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; + } + if state.refused.insert((format as u8, text)) { + ScanVerdict::NotForThisProduct + } else { + ScanVerdict::Ignore + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request( + formats: &[CodeFormat], + prefix: Option<&str>, + hint: Option<&str>, + ) -> HostScannerScanRequest { + HostScannerScanRequest { + formats: formats.to_vec(), + prefix: prefix.map(str::to_owned), + hint: hint.map(str::to_owned), + } + } + + fn valid(formats: &[CodeFormat], prefix: Option<&str>, hint: Option<&str>) -> bool { + validate_request(&request(formats, prefix, hint)).is_ok() + } + + const RECEIPTS: &str = "https://greenmarket.example/r/"; + + #[test] + 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( + qr, + Some(&"é".repeat(MAX_PREFIX_BYTES / 2 + 1)), + None + )); + assert!(!valid(qr, None, Some(&"a".repeat(MAX_HINT_CHARS + 1)))); + for refused in [ + "scan\nto sign in", + "line\u{2028}separator", + "para\u{2029}separator", + "\u{202E}reversed", + "\u{2066}isolate", + "\u{200F}mark", + "\u{061C}arabic letter mark", + "\u{FEFF}byte order mark", + "\u{FFF9}annotation", + ] { + assert!(!valid(qr, None, Some(refused)), "{refused:?}"); + } + } + + #[test] + 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. + 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( + &request(&[CodeFormat::Qr], Some("ab"), None), + CodeFormat::Qr, + "aé" + )); + } + + #[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 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}"); + } + } + // 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] + 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!( + 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.rs b/rust/crates/truapi/src/native.rs index f037a323c9..da6f28a9c4 100644 --- a/rust/crates/truapi/src/native.rs +++ b/rust/crates/truapi/src/native.rs @@ -28,9 +28,10 @@ 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, + NativePocketCallbacks, NativePocketRemoval, NativeScannerCallbacks, }; pub use config::{HostRuntimeConfig, NativeRuntimeConfigError, ProductExecutionConfig}; pub use errors::{HostRejection, NativeChatFieldError, NativeCoreDatabaseError, NativeRendererError}; diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index b1fefb83c1..cb556c5d57 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -336,3 +336,18 @@ pub trait NativeContactsCallbacks: Send + Sync { product_id: String, ) -> Result; } + +/// 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 { + /// 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 5e06ba15c0..11ce12e093 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,33 @@ 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(), + product.execution_kind, + 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 d130bcacad..c85a3c7e7a 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; @@ -278,6 +278,13 @@ impl NativeTrUApiHostRuntime { })) } + /// 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 })) + } + /// 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) { @@ -1179,6 +1186,8 @@ mod tests { panic!("expected a renderer action item") }; assert_eq!(delivered, published); + // 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/platform.rs b/rust/crates/truapi/src/platform.rs index e9bafda279..93b9d61cef 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,10 +35,11 @@ use truapi::latest::{ HostFeatureSupportedResponse, HostLocalStorageChangeItem, HostLocaleSubscribeItem, HostNavigateToError, HostPlatform, HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPushNotificationRequest, HostPushNotificationResponse, - 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}; @@ -3520,6 +3521,49 @@ 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, + /// 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. Optional. The Swift and +/// Kotlin bridges and the JS `scanner` callbacks follow the same rules. +/// +/// - 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. + 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`]. @@ -3562,11 +3606,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/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index 74a24ade1d..1cd835f723 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,21 @@ 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( diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index a0485e8ac0..bb1f668b5b 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -325,6 +325,10 @@ pub struct ProductRuntimeHost { open_operations: Mutex>, } +/// How long after a tap a Worker may still open the scanner, as browsers bound +/// transient user activation. +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 /// worker alive for a product that is gone. @@ -362,6 +366,13 @@ impl ProductRuntimeHost { } } + /// 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) + } + /// Role-neutral services shared with the owning host runtime. pub fn services(&self) -> &Arc { &self.services @@ -1383,7 +1394,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; @@ -1400,9 +1411,13 @@ 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::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/actions.rs b/rust/crates/truapi/src/runtime/actions.rs index 4e4b179db6..a6a5c7f8a2 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), } } @@ -76,6 +81,8 @@ impl ActionChannel { 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(), @@ -86,9 +93,28 @@ 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); + published_at != 0 + && crate::unix_time::current_unix_secs().saturating_sub(published_at) <= window_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); + } + /// End the current subscriber's stream while keeping buffered items for /// the next product connection that subscribes. pub fn detach(&self) { @@ -127,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(); diff --git a/rust/crates/truapi/src/runtime/capabilities/scanner.rs b/rust/crates/truapi/src/runtime/capabilities/scanner.rs index 139e07b3b7..56930648f6 100644 --- a/rust/crates/truapi/src/runtime/capabilities/scanner.rs +++ b/rust/crates/truapi/src/runtime/capabilities/scanner.rs @@ -2,21 +2,75 @@ 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, ProductExecutionKind}; +use crate::runtime::{ProductRuntimeHost, until_cancelled}; + +fn domain(error: latest::HostScannerScanError) -> CallError { + CallError::Domain(HostScannerScanError::V1(error)) +} + +fn unknown(reason: impl ToString) -> 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 + /// 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 }))?; + if self.execution_kind() == ProductExecutionKind::Worker && !self.recently_tapped() { + return Err(domain(latest::HostScannerScanError::NotVisible)); + } + 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(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(unknown( + "the host returned a code the request does not accept", + )); + } + HostScan::Dismissed => latest::ScanOutcome::Dismissed, + 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/services.rs b/rust/crates/truapi/src/runtime/services.rs index 31e56cc32b..febca09435 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 core::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>, @@ -128,6 +134,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(), @@ -250,6 +258,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 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 @@ -475,3 +506,12 @@ impl RuntimeChainProvider for HostChainProvider { }) } } + +/// The open scan. Frees the slot when dropped. +pub 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 5b72e8fde9..5cc4494827 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::{ @@ -51,7 +52,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, @@ -7851,23 +7854,322 @@ 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. +/// 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: HostScan, + park: bool, + asked: Mutex>, + abandoned: Arc, +} + +impl StubScannerPlatform { + fn answering(answer: HostScan) -> Arc { + Arc::new(Self { + answer, + park: false, + asked: Mutex::new(Vec::new()), + abandoned: Arc::default(), + }) + } + + fn parked() -> Arc { + 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)> { + 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 { + scanner_host_for(ProductExecutionKind::App, scanner) +} + +/// [`scanner_host`] for an execution of `kind`. +fn scanner_host_for( + kind: 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(), + 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 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(other) => panic!("unexpected scanner error: {other:?}"), + } +} + +fn scan( + host: &ProductRuntimeHost, +) -> Result> { + 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. +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![], + }, + ) +} + +fn scanned(text: &str, format: v01::CodeFormat) -> HostScan { + HostScan::Scanned { + text: text.into(), + format, + } +} + #[test] -fn scanner_scan_is_unsupported_until_a_host_implements_it() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); +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); + + 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()); + + host.publish_renderer_action(card_tap()).unwrap(); + assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); +} - let result = futures::executor::block_on(Scanner::scan( +#[test] +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 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 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(v01::HostScannerScanRequest { - formats: vec![v01::CodeFormat::Qr], - prefix: None, - hint: None, - }), + HostScannerScanRequest::V1(invalid), + ))); + assert!(matches!( + result, + Err(CallError::Domain( + v01::HostScannerScanError::InvalidRequest { .. } + )) + )); + assert_eq!(scan(&host), Ok(v01::ScanOutcome::Dismissed)); + assert_eq!( + scanner.asked(), + vec![("greenmarket.dot".to_owned(), receipt_request())] + ); +} + +#[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)) + ); + + cancel.cancel(); + // 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::Domain(v01::HostScannerScanError::Unknown { .. })) + )); + 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); +} + +/// 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!(matches!(result, Err(CallError::Unsupported))); + // 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)); } /// A pairing test host, whose wallet answered the Bulletin allowance in-page, diff --git a/rust/crates/truapi/src/v01/scanner.rs b/rust/crates/truapi/src/v01/scanner.rs index 8b04e56de1..7511427618 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,16 +39,20 @@ 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. + /// 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, } diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index 421f5c5ba0..af8b1581a5 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -21,7 +21,7 @@ use crate::platform::SigningHostConfig; use crate::platform::{ ChainProvider, ChatPlatform, ContactsPlatform, GamePlatform, HostInfo, JsonRpcConnection, PairingHostConfig, PermissionStatusHost, PlatformInfo, PocketPlatform, ProductContext, - ProductExecutionKind, ProviderError, RuntimeConfigValidationError, + ProductExecutionKind, ProviderError, RuntimeConfigValidationError, ScannerPlatform, }; use futures::channel::mpsc; use futures::future::{AbortHandle, Abortable}; @@ -847,6 +847,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 +857,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 +872,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { status_host: status, pocket_platform: pocket, game_platform: game, + scanner_platform: scanner, } } @@ -946,6 +950,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); @@ -967,6 +972,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), @@ -1292,6 +1300,7 @@ impl WasmSigningHostRuntime { platform, pocket_platform, game_platform, + scanner_platform, .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { @@ -1305,6 +1314,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), @@ -1500,6 +1512,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); @@ -1518,6 +1531,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); }