Add same-device wallet links to the wallet chooser - #200
Conversation
A wallet can register a URL scheme without registering as a credential handler in this browser, and such a wallet never appears in the chooser. The interaction URL is already validated for the cross-device QR code, so prefixing it with a scheme is all that is needed to hand the same exchange to a wallet on this device. Two schemes, because a native app can only claim the bare `interaction:` scheme while a web app must use the `web+interaction:` form that `registerProtocolHandler()` requires. Nothing in the browser reveals which the user registered, so both URLs are built and the UI offers both. Straight concatenation with no percent-encoding, matching the rule already implemented for the same link elsewhere. Note this is a Digital Bazaar convention rather than a standard: no normative definition of an interaction URL scheme exists yet, so the names live in constants. Returns both URLs from one call so they share a single validation path and a caller cannot render one while silently omitting the other. Addresses #166.
The QR code serves the two-device case: it is displayed here and scanned from a phone. On a phone there is no second device, and a wallet that registered a URL scheme without registering as a credential handler never appears in the chooser, so neither existing path reaches it. Two links, because a native app can only claim `interaction:` while a web app must use the `web+interaction:` form that `registerProtocolHandler()` requires, and nothing in the browser reveals which the user registered. Custom-scheme navigation also fails silently everywhere, with no event to detect it, so no fallback can be inferred -- the user knows whether their wallet is an app or a website, which is the one fact neither the page nor the browser can supply, so both links are offered and the copy says what silence means rather than promising an error that cannot fire. Placed above the QR code inside the existing expander: it needs no second device, so it is the cheaper option when it works. Real anchors rather than scripted navigation, for keyboard and context-menu behavior. Tapping a link emits nothing and changes no mediator state. A user who taps has not cancelled, and if nothing claims the scheme the dialog must remain exactly as it was; dismissal stays with the existing Close button. The visibility condition is the interaction URL that already gates the QR, so a request without one is unchanged. Addresses #166.
The harness drives the presentational wizard directly, so it has to build the prefixed URLs the mediator would normally derive. Adds a `link` query param to exercise the section with the links suppressed and the QR code still present, which no other combination produces. The fake interaction URL now carries `iuv=1`, matching what the mediator requires of a real one. The harness bypasses that validation, so the old value passed, but it misrepresented the input the layout is built for. Addresses #166.
Nothing in the browser reveals whether a wallet app is installed, so the link cannot be shown conditionally on that. Form factor is a property the browser does report, and it is the one that matters: a wallet app can only be installed on a phone or tablet, and a desktop is already served by the QR code, which needs no installed app and no scheme registration. Detected by pointer capability rather than viewport width. The chooser renders in a 500px popup on the desktop -- the same width range as a phone screen -- so a width breakpoint cannot tell them apart. A coarse primary pointer that cannot hover separates a touch device from a mouse-driven desktop without parsing user agent strings. The web wallet link stays on every device, since a registered web wallet works the same on either, so the desktop chooser now offers one link and the QR code, and a phone offers both links. Relabels the second link "Web wallet" and the heading "Already have a wallet? Open it here:". With the app link absent on the desktop, the pair reads as a choice between a website and an installed app rather than two spellings of the same thing. The silent-failure line now points at the QR code as the fallback. The harness reads the emulated pointer the same way, and takes an `app` param to force the link on for layout work on a desktop project. Addresses #166.
The links were inside the collapsible block with the QR code, so a wallet the user already has installed was reachable only after expanding "Don't see your wallet?" -- while registered web wallets were listed immediately. That ranked an installed wallet below a wallet the user may not have, and framed it as a fallback for when the listed options fail. The links now sit in their own always-visible section between the wallet list and the QR expander, so both ways of reaching a wallet the user already has are offered at the same level. The QR code stays behind its expander: it needs a second device, which is the less common case, and it is tall enough to push everything else off a phone screen. Its toggle is relabeled "Use a wallet on another device", since "Don't see your wallet?" now sits below a section that answers that question and would read as a contradiction. The zero-wallet greeting no longer says to scan the QR code below, which was the only option when it was written. The silent-failure note is de-emphasized: it only matters after a tap that did nothing, so it should not compete with the links it explains. Addresses #166.
As a pair of small centered buttons the links read as an afterthought
trailing the wallet list, even after they were moved out from behind the
QR expander. A wallet the user already has should look like the same kind
of choice as a registered one, not like a secondary control.
They now use the same `wrm-item` row shape as the hint list: full width,
icon on the left, a bold title and a caption saying what each one is.
Titles name the action ("Open my wallet app") rather than the object, and
the captions distinguish an installed app from a wallet website -- the
question the user can actually answer, without naming URL schemes.
The rows inherit the dialog's text color. As anchors they would otherwise
take the browser's blue link color and read as a different species from
the wallet rows beside them.
The zero-wallet greeting is cut to one line. It explained the browser
registration model directly above the options that answer it, which only
pushed them down the screen. The section heading is dropped in that case
too, since the greeting already asks the question; it stays when wallets
are listed and the rows need a label.
Addresses #166.
With nothing in the hint list, a wallet app installed on the device is the likeliest thing the user actually has -- there is no registered wallet to prefer, and the QR code needs a second device. Marking that row as the recommended one gives the user a default to act on rather than three equal-looking options and no basis for choosing. Conditional on the hint list being empty. When wallets are listed, one of them is the likelier choice, so both link rows stay neutral and the list keeps its precedence. Styled as a left accent bar over a lightly tinted background, with a dark mode variant, rather than the filled `wrm-primary` treatment used for dialog buttons: at full row width that would overpower the section and compete with the wallet list beside it. Addresses #166.
A `web+` scheme resolves only to a handler registered through `navigator.registerProtocolHandler()`. Safari has never shipped that API, so in Safari the link cannot resolve at all -- and rather than failing silently the way an unclaimed native scheme does, it reports "the address is invalid". The row was offering a guaranteed error on every iPhone. Hidden by feature detection rather than a platform check: the API's presence is exactly the condition that decides whether the link can work, and needs no revision if Safari ships it or another engine drops it. On Android, where Chrome implements the API, both rows still appear. The failure note reads "this link" when only one row is shown, since "these links" would name something not on screen. The zero-wallet greeting no longer promises a wallet to open when neither row renders -- a desktop Safari has no app link and no web link -- and points at the QR code instead. Addresses #166.
Testing the mediator from a phone means serving it under a tunnel hostname, which requires changing `server.host` and `server.baseUri` -- values in a tracked config that differ per machine. `configs/dev.js` now imports `configs/local.js` last, when present, so those overrides win. The file is gitignored; absent it, behavior is unchanged.
The rows inherited `wrm-flex-row`'s centering, so their icon and text sat in the middle while the wallet rows above them started at the left edge. They now start at the left, with a narrower icon column, so the two kinds of row line up. The section heading and the silent-failure note are left-justified for the same reason. Adds 20px to the cross-device popup height. The dialog does not size itself to its content, so each section added to it has to be paid for here by hand; without the extra height the Close button can scroll out of reach. This is a stopgap until the dynamic sizing work lands. Addresses #166.
The icons had a fixed 32px width, so when the icon font fails to load the reserved box renders as an empty square sitting in the row -- the icon's absence became more visible than the icon. Sizing the element from the glyph means a missing font costs alignment, not an artifact. The wallet rows above keep their fixed 48px slot: those hold <img> logos with their own fallback, not font glyphs. Addresses #166.
The override is only discoverable by reading `configs/dev.js`, and anyone using it has to derive the file's shape and the bedrock config keys involved from scratch. `configs/local.js.example` is a copyable starting point covering the case the override exists for: serving the mediator under a tunnel hostname so a phone can reach it. It carries the reasoning that is easy to get wrong -- why `server.host` and `server.baseUri` are set rather than `server.domain`, and why the tunnel must point at the HTTPS port rather than the HTTP one, which would redirect to itself forever. The hostname is a placeholder. Also corrects the Configuration section, which pointed at `./configs/authn.localhost.js`. That file is `authn.localhost.js` at the repository root; the tunable config is `configs/dev.js`.
|
Reviewed the 10 files unique to this branch (#201's four are covered on that PR). The design reasoning in the comments is good — most of this is the code not matching it yet. Three to fix, then smaller things. 1. The new copy is false in its most common case
Also gone: "or visit your wallet website to register" (a first-timer now gets two rows that assume they already have one, and no route to getting one) and the RP's name. 2. The three harness URLs this PR documents show nothing
qrEnabled.value && linkEnabled.value && appLinkEnabled.value ? ... : ''So none render a row, and 3. Activating a row navigates the mediator away with nothing sentBoth anchors are plain I can't say what the RP sees: the popup path detects the dialog leaving by polling Smaller
Checked clean: Pre-existing, not this PR
|
Import by file URL, not filesystem path. `import()` takes a URL, so a bare
absolute path truncates at a `#` in any parent directory name -- reporting a
module nobody asked for -- and on Windows parses the drive letter as a URL
scheme. Reproduced both with a `#` in the path; `pathToFileURL` fixes it.
Drop `server.baseUri` from the example. It is the lazily-evaluated template
`https://${server.host}`, so it already follows `host`; setting both ships two
values that must agree, which is the mismatch the surrounding prose warns
about. Verified by removing it: both the tunnel and local origins still serve.
Correct the `bindAddr` justification. `configs/dev.js` hard-assigns
`config.server.bindAddr`, so setting `server.domain` cannot affect it -- the
reason to prefer `server.host` is that `host` appends the port.
Point `configs/dev.js` at `server.host` rather than `server.domain`. That
comment is the first thing a reader opens and it recommended the knob the
example warns against.
Note that the file cannot register bedrock event handlers, since it is
imported after `@bedrock/config-yaml` and would trip that package's
last-import assertion with a message that does not explain itself.
Note that overriding `server.host` stops `@bedrock/express` sending
`Access-Control-Allow-Private-Network`, which it gates on `host` containing
`localhost`. Traffic through the tunnel is unaffected, but a public-origin
coordinator reaching the local mediator directly will start failing Chrome's
private-network preflight while the file is in place.
Name `local.js` as the development override channel, since
`@bedrock/config-yaml` is a third nominal channel with no stated precedence.
Show restricting `server.bindAddr` to loopback in the example. The all-
interfaces default exists for Docker and is documented as such, but nothing
offers the alternative at the point of use.
Correct the README's Node and npm versions, which read v16+/v8+ against an
`engines.node` of `>=24`.
| v-if="collapsible" | ||
| class="wrm-dark-gray" | ||
| style="padding-top: 0.5em"> | ||
| Already have a wallet? Open it here: |
There was a problem hiding this comment.
This text is a little confusing because it can show up when the user "already has a wallet that is in the list above where text appears"
| Already have a wallet? Open it here: | |
| Have a wallet installed on this device? Open it here: |
This suggestion might not be good enough because are Web wallets that only register the web+interaction scheme "installed on the device?" ... perhaps not. We need some better language, we should bring this to others to bikeshed it collectively.
Maybe we want "Don't see your wallet?" to be a simple text that we use for all of the cases -- and then we have buttons for opening a wallet "on this device" and the "QR code" for opening "on another device". That might solve this generally.
| <div class="wallet-link-text"> | ||
| <strong>Open my web wallet</strong> | ||
| <div class="wrm-dark-gray wallet-link-caption"> | ||
| A wallet website you already use |
There was a problem hiding this comment.
Similarly, this can show up even if there's a "wallet website you already use in the list above where this appears". So we should bikeshed this a bit too.
Emit the out-of-band response when a row is activated. Both rows are plain anchors, so where a handler is registered the navigation replaces the mediator's own context and `crossDevice()` never ran -- the relying party was left waiting on a dialog that no longer exists, and the popup path cannot detect that because it polls `handle.closed`, which a same-context navigation never sets. The rows now emit before navigating. Where no handler is registered the navigation is a no-op, so the dialog stays usable either way; the two outcomes are indistinguishable from script, so both are prepared for. Define `crossDevice` in `ThirdPartyMediatorWizard`. The template bound `@cross-device` with no handler in `setup()`, so any path reaching it threw. Present on main, but this branch is the first to give a user a way there. Correct the zero-wallet copy. `showWarning` is `hints.length === 0` and hints are filtered through `matchCredentialRequest()`, so an empty list means nothing registered here holds what was asked for -- not that no wallet is registered, which is wrong exactly when the user has a wallet lacking the credential. Restores the request-type distinction, the relying party's name, and the route to registering a wallet, which a first-timer needs and which two rows assuming an existing wallet had removed. Reveal the silent-failure note only after activation. It asserted that no wallet handles the link while a matching wallet could be listed above it. Add the wallet link rows to the layout suite, forcing both gates on so the rows are covered on every project rather than appearing only where the browser happens to satisfy them. Fix the documented harness URLs, which omitted `qr=1` and so rendered nothing at all, and have the harness import `IS_MOBILE_DEVICE` and `SUPPORTS_WEB_WALLET_LINK` rather than re-implementing them. Return the parsed interaction URL rather than the raw string, since `new URL()` strips tabs and newlines and the value that shipped could differ from the one validated. Remove the dead `.wallet-link.wrm-button` rule and its orphaned comment. Addresses #166.
|
Applied in 67070f5. Verified each claim against the code first; all three blocking ones held, and item 3 was worse than latent. 1. The copy was falseConfirmed: Now request-type aware, with the relying party's name and the route to registering a wallet restored:
You were right that two rows assuming an existing wallet, with no route to getting one, is worse for a first-timer than the copy I replaced. 2. The documented harness URLs showed nothingConfirmed — 3. Activation navigated away with nothing sentThis was the one worth catching. The rows now emit the out-of-band response on click, before the navigation. Your reasoning about I did not add While there: Smaller
Not changed, with reasons
Pre-existing
|
Phase 2 of cross-device-wallet-selection-spec.md, whose Phase 1 (the cross-device QR code) shipped in 7.2.0. Addresses #166.
What this adds
Two rows in the wallet chooser, shown whenever a relying party supplies an
interaction URL:
interaction:<url>web+interaction:<url>They reach a wallet that registered a URL scheme but is not registered as a
credential handler in this browser. Such a wallet was previously unreachable:
it never appears in the chooser, and a QR code cannot be scanned by the device
displaying it — the case a phone always hits.
Why it belongs here and not in coordinator sites
The same argument that moved the QR code into CHAPI. A coordinator site cannot
know which wallets a user has registered, so it cannot decide which link to
show, or whether to show one at all. The chooser can. Coordinators keep a
single button that calls CHAPI and get this behavior — and later fixes —
without changing their code.
Each row appears only where it can work
rather than viewport width: the chooser renders in a 500px popup on the
desktop, the same width range as a phone, so a breakpoint cannot tell them
apart.
navigator.registerProtocolHandler()existing.Nothing can claim a
web+scheme without it, and Safari has never shippedthe API — there the link cannot resolve and the browser reports the address
as invalid, so the row would offer a guaranteed error. This covers every
browser on iOS, since they are all WebKit; feature detection gets that right
without enumerating browsers.
Neither row is a fallback for the other: a native app can only claim the bare
scheme, a web app only the
web+form, and nothing in the browser revealswhich the user registered. Custom-scheme navigation also fails silently, with
no event to detect it, so no fallback can be inferred. The user knows whether
their wallet is an app or a website, so both are offered where both can work
and the copy says what silence means.
Layout
Screenshots from the
/test/wallet-chooserharness on a real iPhone, two perbrowser: no registered wallet, and one registered wallet. Chrome on iOS is
WebKit under the hood, so it hits the same gates as Safari — in all four only
the wallet app row appears, because neither browser has
navigator.registerProtocolHandler()and so neither can claimweb+interaction:.Chrome on iOS
Safari on iOS
Note the failure note reads "this link", singular, in all four: the copy adapts
when only one row is shown. A build where both rows render — Chrome or Firefox
on Android or the desktop — shows "these links".
The rows sit between the wallet list and the QR expander, in their own
always-visible section, using the same
wrm-itemrow shape as the wallet list— so opening a wallet you already have reads as the same class of action as
picking a listed one. The QR stays behind its expander: it needs a second
device, and it is tall enough to push everything else off a phone screen.
With no wallet registered in this browser, the wallet app row is marked
recommended — there is no registered wallet to prefer and the QR needs a
second device, so an installed app is the likeliest thing the user has.
Tapping a row emits nothing and changes no mediator state. A user who taps has
not cancelled, and if nothing claims the scheme the dialog must remain as it
was; dismissal stays with the existing Close button, which resolves
OutOfBandas Phase 1 established.Note on the schemes
interaction:andweb+interaction:are a Digital Bazaar convention, not astandard. Verified against the VCALM Editor's Draft on 2026-08-19: it defines
no interaction URL scheme, treating interaction URLs as plain HTTPS carrying
iuv=1. The names live in two constants inweb/mediator/constants.jsso arename after CCG/VCALM agreement is a one-line change.
This ships experimentally, which the parent spec's Open Question 1 permits
("after agreement or experimentally to gather feedback"). Feedback is what
agreement needs.
Also included
its content, so each section added to it is paid for by hand here; without
it the Close button can scroll out of reach. A stopgap until the dynamic
sizing work lands.
link rows are available.
read "Don't see your wallet?", which now sits below a section answering that
question.
/test/wallet-choosergainsappandwebparams to force either row onor off, since the real gates depend on the browser.
Testing
npm run test:e2eon chromium, webkit, iphone, and android-pixel: 152 passed.The 20 failures are the pre-existing
has no horizontal overflowassertion,marked KNOWN FAILING in the spec file and unchanged by this branch (verified by
stashing). Firefox is excluded — it stalls on the author's machine
independently of these changes, including
browserContext.newPagetiming outon
main.Verified by hand on a real iPhone against a tunnelled mediator: the wallet app
row appears, the web wallet row does not, and the rows render correctly in the
first-party popup.
Not covered
No web wallet registers
web+interaction:yet, so that row is inert where itshows — Chrome logs "the scheme does not have a registered handler". It is
harmless but unexercised; worth deciding whether to ship it now or hold it
until a wallet registers.