Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
7f47b42
docs(rfc): let a product scan a code with the host's viewfinder
valentinfernandez1 Oct 7, 2026
d61e568
Merge branch 'main' into rfc/host-scanner
valentinfernandez1 Oct 7, 2026
d8247f4
docs(rfc): tighten the scanner request rules after review
valentinfernandez1 Oct 7, 2026
c5c23f0
feat(truapi): check scanner requests and filter scanned codes in the …
valentinfernandez1 Oct 7, 2026
e829b39
feat(truapi): open the host scanner for a product, one scan at a time
valentinfernandez1 Oct 7, 2026
e6d11cf
fix(truapi): stop waiting on the contact picker when the product cancels
valentinfernandez1 Oct 7, 2026
93cd465
fix(native): route contact pick errors through the shared helper
valentinfernandez1 Oct 7, 2026
b289f48
feat(native): expose the scanner to Swift and Kotlin hosts
valentinfernandez1 Oct 7, 2026
e13c4d3
feat(truapi-host): serve the scanner in the browser bridge and the te…
valentinfernandez1 Oct 7, 2026
a9bb9f2
feat(host-cli): answer product scans from the environment
valentinfernandez1 Oct 7, 2026
16de425
docs: describe the scanner capability and add its changeset
valentinfernandez1 Oct 7, 2026
505cfa5
style: format the scanner code that cargo fmt does not reach
valentinfernandez1 Oct 7, 2026
d65a87a
test(host-cli): check the scanner end to end against a CLI host
valentinfernandez1 Oct 7, 2026
78a18d6
test(host-cli): run the scanner checks on the pairing host too
valentinfernandez1 Oct 7, 2026
e71ec5f
fix(truapi): answer an internal cancel with the method's own error
valentinfernandez1 Oct 7, 2026
828e534
fix(truapi): tighten scanner request checks after the second review
valentinfernandez1 Oct 7, 2026
7d9651b
docs(rfc): keep pairing requests from products and scan only on screen
valentinfernandez1 Oct 7, 2026
3269b8b
Merge branch 'rfc/host-scanner' into feat/host-scanner-core
valentinfernandez1 Oct 7, 2026
e5e54cb
Merge branch 'rfc/host-scanner' into feat/host-scanner-core
valentinfernandez1 Oct 7, 2026
69419db
docs(rfc): only a tap on host-drawn UI lets a Worker scan
valentinfernandez1 Oct 7, 2026
de6af4a
Merge branch 'rfc/host-scanner' into feat/host-scanner-core
valentinfernandez1 Oct 7, 2026
9936044
docs(rfc): rewrap the visibility rule
valentinfernandez1 Oct 7, 2026
fd364b6
fix(truapi): never hand a product a pairing request it scanned
valentinfernandez1 Oct 7, 2026
72488ff
feat(truapi): open the scanner only for what the user is looking at
valentinfernandez1 Oct 7, 2026
99afc0d
Merge remote-tracking branch 'origin/main' into rfc/host-scanner
valentinfernandez1 Oct 7, 2026
33b6dda
Merge branch 'rfc/host-scanner' into feat/host-scanner-core
valentinfernandez1 Oct 7, 2026
e277cff
refactor: say the scanner rules once and trim the scanner tests
valentinfernandez1 Oct 7, 2026
24a3ad7
test: merge overlapping scanner tests
valentinfernandez1 Oct 7, 2026
905abfa
fix(scanner): count card taps a native host publishes
valentinfernandez1 Oct 8, 2026
6e01ca5
refactor(scanner): use plain pub for the scanner runtime items
valentinfernandez1 Oct 8, 2026
b4157ea
fix(scanner): count only delivered taps and test the native path
valentinfernandez1 Oct 8, 2026
36e35fb
fix(scanner): stamp a tap before the product can react to it
valentinfernandez1 Oct 8, 2026
20fe95a
Merge remote-tracking branch 'origin/main' into feat/host-scanner-core
valentinfernandez1 Oct 8, 2026
f0a9f1f
Merge remote-tracking branch 'origin/main' into feat/host-scanner-core
valentinfernandez1 Oct 8, 2026
9b2767a
test(scanner): fold the native tap check into the renderer action test
valentinfernandez1 Oct 8, 2026
f4a343f
test(scanner): drop the CLI scanner phase, which repeated the core tests
valentinfernandez1 Oct 8, 2026
87b3e9e
fix(scanner): refuse a pairing handshake in any form a wallet reads
valentinfernandez1 Oct 8, 2026
71fe4ff
Merge remote-tracking branch 'origin/main' into feat/host-scanner-core
valentinfernandez1 Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/host-scanner-core.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/host-scanner.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions docs/rfcs/host-scanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodeFormat>,
/// Start the text must have, ignoring ASCII letter case. At most 256 bytes of UTF-8.
pub prefix: Option<String>,
Expand All @@ -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 },
}
Expand Down Expand Up @@ -112,16 +112,19 @@ 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
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
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.
Expand Down
124 changes: 61 additions & 63 deletions ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -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<T>(_ 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
Expand All @@ -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<T>(_ 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
Expand All @@ -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<T>(_ 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
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -697,26 +685,6 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable {
}
}

private func withHostRejection<T>(_ 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<T>(_ 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<T>(_ operation: () throws -> T) throws -> T {
do {
return try operation()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1185,6 +1163,26 @@ public final class TrUAPIProductExecution: TrUAPIProductExecutionProtocol, @unch
}
}

private func withHostRejection<T>(_ 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<T>(_ 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,
Expand Down
6 changes: 6 additions & 0 deletions js/packages/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
```

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions js/packages/truapi-host/src/test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
: {}),
};
}

Expand Down
Loading
Loading