Skip to content

feat(truapi): let products scan codes with the host's viewfinder - #1307

Merged
valentinfernandez1 merged 38 commits into
mainfrom
feat/host-scanner-core
Oct 9, 2026
Merged

valentinfernandez1 merged 38 commits into
mainfrom
feat/host-scanner-core

Conversation

@valentinfernandez1

@valentinfernandez1 valentinfernandez1 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Host scanner

A product can call scanner.scan to have the host open its own QR and barcode viewfinder. The product gets back the text and format of the code the user scanned. It never sees the camera, so there is no permission prompt.

What the core does

When a product calls scanner.scan, the core:

  1. answers Unsupported if the host has no scanner
  2. answers InvalidRequest if the request breaks a limit
  3. answers NotVisible for a Worker the user has not tapped in the last 5 seconds
  4. answers Busy if another scan is open, since the device has one viewfinder
  5. asks the host to open its viewfinder. The host answers NotVisible itself for an App or Widget that is not on screen.
  6. checks the code again, so a code the product did not ask for comes back as Unknown

Steps 1 to 4 never reach the host. A tap is any action the host publishes to the product's renderer.

A pairing handshake never reaches a product, whatever the product asked for. Whoever answers one pairs with the device that showed it. The core matches it by shape, in every form a wallet reads: any handshake= value or the bare handshake, in hex with or without 0x, of any proposal version.

If the product cancels, the core stops waiting, which tells the host to close the viewfinder.

Scanning needs no signed-in session.

What hosts get

  • Rust: an optional ScannerPlatform, installed with set_scanner_platform.
  • Swift and Kotlin: ScannerHostBridge and setScanner. The bridge receives the product id, the execution kind and the request.
  • ScanFilter: a host calls it for every code the camera reads. It answers accept, "not for this product", or ignore, so every host applies the same rules.
  • Browser: optional scanner callbacks. dot.li supplies none, so it answers Unsupported.

Testing

  • Test host: createMockHost({ scanner: answer }) serves a scanner, and setScanAnswer changes the next answer. Without it, scans are Unsupported.
  • CLI host: both roles answer with TRUAPI_SCAN_TEXT as a QR code, or a dismissal when it is unset.
  • Full battery: Scanner/scan passes on the signing host. The run used the public test mnemonic, which has no username and no personhood, so the examples that need them fail. None of those failures involve the scanner. The paired phase needs a real identity and was not run.

Contact picker

  • The core stops waiting on the contact picker when the product cancels, which tells the host to close it.
  • The Kotlin contacts adapter lets a cancel through instead of turning it into an error.
  • Swift has one shared error helper in place of four private copies.

Notes for review

  • cargo fmt skips the files that lib.rs declares inside the runtime_items! macro, so no CI job formats them. The new code in those files was formatted with rustfmt directly.
  • The browser bridge needs four connections that codegen does not write. A test runs a scan through the real WebAssembly core to catch a missing one.
  • A JS host is not told when a scan is cancelled, because a promise cannot be withdrawn.

valentinfernandez1 and others added 14 commits October 7, 2026 11:16
Adds the Host-drawn scanner RFC and its protocol surface: the Scanner
trait (wire id 25) with scan, its v01 payloads and versioned envelopes,
and the regenerated goldens. The runtime answers Unsupported until a host
serves it, so the CLI battery and the playground Diagnosis skip the
service for now.
- Add Codabar, which both platform decoders read.
- Compare the prefix ignoring ASCII case, since QR codes often store URLs
  in capitals.
- State the units of each limit, and refuse hints with line separators or
  direction-changing characters.
- Say hosts report UPC-A as EAN-13 before filtering, and that a refused
  host answer reaches the product as Unknown.
- Drop the UniFFI derives until a native caller needs them.
- Bump @parity/truapi-host too, since its runtime now answers the method.
- Ask in the RFC whether background executions may open the viewfinder.
…core

Request limits, the prefix and format match, and ScanFilter, which both
native hosts call for every code the camera reads so they accept the same
codes and show the same messages.
Adds ScannerPlatform as an optional host capability. The core refuses an
invalid request before any viewfinder opens, allows one open scan per host,
closes the viewfinder when the product cancels, and checks the host's answer
again before the product sees it.
contacts.pick ignored the call's cancellation, so a withdrawn pick kept the
picker open until the user acted. The core now drops its wait, which is the
host's signal to close the picker.
The Kotlin contacts adapter caught CancellationException and answered it as
a host rejection, so a withdrawn pick never unwound. It now uses
withHostRejection, which lets cancellation through. On Swift the four
private copies of withHostRejection become one file-level pair, which the
contacts adapter now uses too.
NativeScannerCallbacks, the ScannerHostBridge wrappers with setScanner,
and ScanFilter and ScanVerdict, which a host's viewfinder calls for every
code it reads.
…st host

Wires the scanner capability through the WASM runtimes and the worker
handshake. The mock host serves a scanner when created with a scanner
answer, and setScanAnswer changes the next one. The Rust MockPlatform
gets the same control so the two mocks stay in step.
Both CLI host roles serve the scanner. A scan answers TRUAPI_SCAN_TEXT as a
QR code, or a dismissal when it is unset, so the battery now runs the
Scanner example instead of skipping it.
These modules are declared inside the runtime_items! macro, so cargo fmt
skips them. Formatted with rustfmt directly, touching only lines this
branch added.
scripts/battery.sh --scanner-host (make e2e-scanner-cli) starts a signing
host that scans a fixed code and checks, over the real wire and without a
session, that a matching code reaches the product, that a code or format
the product did not ask for never does, and that an invalid request is
refused before the host is asked.
Scanning needs no session, so the pairing host runs the same cases unpaired.
@valentinfernandez1 valentinfernandez1 added the rust Pull requests that update rust code label Oct 7, 2026
@valentinfernandez1
valentinfernandez1 requested a review from a team as a code owner October 7, 2026 16:04
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

This pull request touches an app, which is not built by default. Add a label for each build you want:

  • iOS simulator build, ios-simulator-build: builds the iOS app, runs its tests and attaches a build for the iOS Simulator on a Mac.
  • iOS device build, ios-device-build: attaches a signed build that installs on a registered iPhone or iPad.
  • Android build, android-device-build: attaches an APK that installs on an Android phone or an emulator.

Each starts as soon as it is added and follows the branch from then on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation javascript Pull requests that update javascript code host-ios Touches the iOS host tree host-android Touches the Android host tree labels Oct 7, 2026
Review of #1307:
- scanner.scan and contacts.pick turned any cancelled wait into
  CallError::Cancelled. Only the dispatcher may answer that, for a call the
  product withdrew, so both now answer Unknown with the reason.
- ScanFilter names each wrong code once per scan, so two codes read in
  turn no longer repeat the message on every frame.
- Hints may not carry U+061C, the last bidirectional mark missing.
- until_cancelled is private again, and services use core atomics.
- ScannerPlatform says a new scan can arrive before the host has closed a
  cancelled one, and that a JS host is not told about a cancel.
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

CI Status: 24 required jobs green, 21 passed and 3 skipped by path filter.

All job results
job result
android-bindings success
bundle-size success
changes success
changeset-guard success
cli-package success
codegen success
e2e skipped
explorer success
headless-install success
host-android-bindings success
host-android-detekt success
host-wasm success
ios-bindings success
ios-swift success
licenses success
playground success
provider-android-bindings skipped
release-guard success
rust success
ts-client success
ts-debugger success
ts-host success
wasm-provider success
workflow-lint skipped

Signing credentials: failure as of 2026-10-09, a release may fail

Commit 71fe4ff9 · run log

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Bundle size report

Compared with main at 6843c76.

Raw Gzip Brotli
truapi-host 7.86 MiB (+8.9 KiB, +0.1%) 5.65 MiB (+2.9 KiB, +0.1%) 5.38 MiB (+2.3 KiB)
truapi-provider 4.03 MiB 1.27 MiB 959.8 KiB
truapi 6.01 MiB (+78 B) 849.3 KiB (+48 B) 695.9 KiB (-8 B)
Total 17.90 MiB (+9.0 KiB) 7.75 MiB (+2.9 KiB) 7.00 MiB (+2.3 KiB)

WebAssembly modules

Raw Gzip Brotli
truapi-host/wasm/web/truapi_server_bg.wasm 2.75 MiB (+8.1 KiB, +0.3%) 980.8 KiB (+2.7 KiB, +0.3%) 739.5 KiB (+2.2 KiB, +0.3%)
truapi-host/wasm/web/truapi_verifiable_bg.wasm 4.89 MiB 4.64 MiB 4.61 MiB
truapi-provider/truapi_provider_bg.wasm 3.99 MiB 1.26 MiB 952.1 KiB
Changed files (7)
Raw Gzip Brotli
truapi-host/wasm/web/truapi_server_bg.wasm 2.75 MiB (+8.1 KiB, +0.3%) 980.8 KiB (+2.7 KiB, +0.3%) 739.5 KiB (+2.2 KiB, +0.3%)
truapi-host/generated/host-callbacks-adapter.js 6.9 KiB (+300 B, +4.4%) 1.5 KiB (+52 B, +3.4%) 1.4 KiB (+47 B, +3.5%)
truapi-host/generated/worker-callbacks.js 7.2 KiB (+266 B, +3.8%) 1.5 KiB (+32 B, +2.2%) 1.2 KiB (+9 B, +0.7%)
truapi-host/generated/host-callbacks.js 10.5 KiB (+235 B, +2.2%) 3.4 KiB (+63 B, +1.9%) 2.8 KiB (+34 B, +1.2%)
truapi-host/web/create-worker-host-runtime.js 54.0 KiB (+61 B, +0.1%) 11.8 KiB (+11 B, +0.1%) 10.3 KiB (+5 B)
truapi/explorer/codegen/types.js 214.0 KiB (+39 B) 29.7 KiB (+28 B, +0.1%) 23.8 KiB (+13 B, +0.1%)
truapi/playground/codegen/truapi-dts.js 284.6 KiB (+39 B) 49.2 KiB (+20 B) 39.7 KiB (-21 B, -0.1%)

Commit: 71fe4ff

Review of #1307:
- A format may be named only once, so the list the filter checks on every
  camera frame stays as short as the formats.
- Hints may not carry invisible characters (zero-width, word joiners,
  byte order mark, annotation marks).
- One unknown() helper builds the handler's catch-all error.
- The JS mock keeps its scan answer only when it serves a scanner, so it
  holds no default that can never be read.
- setScanAnswer is no longer listed as part of host-api-test-sdk's surface,
  which has no scanner.
- The README line for make e2e-scanner-cli names both host roles.
@github-actions github-actions Bot added the rfc label Oct 7, 2026
A code the core would accept as a pairing request is never delivered,
whatever the request's prefix, since it lets whoever answers it pair with
the device that showed it. The prefix stays optional: barcodes have none,
and a required one would not stop a product asking for the pairing link.

Settles the open question: an App or Widget scans only while it is on
screen, and a Worker only within 5 seconds of a user tap; anything else
is answered with the new NotVisible error.
# Conflicts:
#	docs/rfcs/host-scanner.md
Whoever answers a pairing link pairs with the device that showed it, so
the scan filter and the core's re-check refuse any code the core would
accept as a pairing request, with or without the pair link around the
handshake and whatever the request's prefix.
A Worker may scan only within 5 seconds of a renderer action the host
delivered to it; otherwise the core answers NotVisible without asking the
host. For an App or Widget the host answers HostScan::NotVisible when it
is not on screen, so native hosts now receive the execution kind.
# Conflicts:
#	rust/crates/truapi/src/runtime/tests.rs
@valentinfernandez1
valentinfernandez1 added this pull request to stack #1311 October 7, 2026 18:13
@valentinfernandez1 valentinfernandez1 changed the title feat(truapi): serve the host scanner in the core, bindings and test hosts feat(truapi): let products scan codes with the host's viewfinder Oct 7, 2026
The host's viewfinder rules now live only on ScannerPlatform. The native
trait, the Swift and Kotlin bridges, RUNTIME.md and the truapi-host README
point to it instead of repeating it, and the setter docs are one line.
The tests keep every case but drop the scaffolding: case tables instead of
one assert per block, one scanner stub constructor, and the CLI scanner
cases inline in their battery script.
Same behaviour covered with fewer tests:
- The runtime host-answer tests become one table, and the two Worker tap
  tests one test.
- The request validation tests become one.
- The Rust mock and CLI scanner unit tests are gone: the mock parity test and
  the CLI battery already exercise both.
- The CLI battery keeps one case each for accepted, refused by the core and
  refused before the host, since unit tests cover the variations.
- The two mock client scanner tests become one.
Base automatically changed from rfc/host-scanner to main October 7, 2026 19:13
Native executions publish renderer actions straight to the channel they
share with every connection, so the tap never reached the connection and
a Worker on Android or iOS was always told NotVisible. The channel now
keeps the time of its last action, which every path goes through.
A renderer action the channel refuses no longer opens a scan window. A
native test publishes a tap on the execution and checks it on the
connection, which is the path Android and iOS use. The backdating setter
is test-only.
The tap time is written before the action reaches a live subscriber, so a
Worker that scans as soon as it hears the tap finds it. A refused action
still does not count, and a test pins that.
# Conflicts:
#	README.md
#	docs/rfcs/host-scanner.md
#	rust/crates/truapi/src/runtime/capabilities/scanner.rs
#	rust/crates/truapi/src/runtime/tests.rs
#	rust/crates/truapi/src/v01/scanner.rs
The tap check reuses the native renderer action test's setup instead of
repeating it. The hint test keeps one refused character per rule, a dead
error mapping goes, and an unrelated test keeps main's formatting.

@filvecchiato filvecchiato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good apart from one gap in the pairing-request check, inline.

Comment thread rust/crates/truapi/src/host_logic/scanner.rs Outdated
The check went through the core's own decoder, which reads only a bare V2
proposal after the first `?handshake=`. Wallets also read `0x` hex, V1
proposals, and a handshake among other query items, so those reached the
product. The check now goes by shape: any `handshake=` value or the bare
text, in hex with or without `0x`, starting with a known version tag and
long enough for the device keys.

@filvecchiato filvecchiato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

# Conflicts:
#	rust/crates/truapi/RUNTIME.md
#	rust/crates/truapi/src/host_core.rs
@valentinfernandez1
valentinfernandez1 added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 84282d0 Oct 9, 2026
44 checks passed
@valentinfernandez1
valentinfernandez1 deleted the feat/host-scanner-core branch October 9, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation host-android Touches the Android host tree host-ios Touches the iOS host tree javascript Pull requests that update javascript code rfc rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants