MetaMask / MetaMask/connect-monorepo
iOS Safari opens metamask.io instead of metamask:// on connect() — deeplink fires outside the user-gesture window
- Dominant language
- TypeScript
- Stars
- 12
- Forks
- 11
- PR merge metrics
- No merged PRs in 30d
Description
### Affected versions
- `@metamask/connect-multichain@0.15.0`
- `@metamask/connect-solana@1.2.0` (which depends on the above)
Reproduced on current iOS Safari on iPhone.
### Symptom
A dapp calls Wallet Standard `connect()` on the MetaMask Connect connector from inside a user tap handler. **Sometimes** (more often on a cold first-of-day load) Safari navigates the current tab to `https://metamask.io` instead of handing off to the MetaMask iOS app via the `metamask://` custom scheme. Refresh or wait and retry usually gets a successful hand-off; the failure rate is latency-correlated.
Phantom and other wallets on iOS Safari are unaffected (when they have a working universal-link / sync-deeplink path).
A sister bug exists for `signMessage` / `sendCalls` / `writeContract` after a successful connect: #252.
### Root cause
iOS Safari only honors a navigation to a custom scheme (`metamask://…`) when the navigation runs inside the synchronous task that originated from the user's tap. Once any awaited promise resolves first (or a `setTimeout` callback fires), the gesture token is lost — Safari treats the `window.location.href = '…'` assignment as a programmatic navigation and resolves the host (`metamask.io`) in the current tab instead of handing off to the app.
In `_deeplinkConnect` (`@metamask/connect-multichain/dist/src/multichain/index.js`, around lines 700-770) the deeplink is opened on either of two paths, **both of which exit the gesture window**:
1. **Warm transport** — schedules the open on a 250 ms `setTimeout`:
```js
if (this.transport.isConnected()) {
timeout = setTimeout(() => {
this.openSimpleDeeplinkIfNeeded();
}, 250);
}
```
The 250 ms delay alone is enough to exit Safari's gesture-token window on every call.
2. **Cold transport** — registers a one-shot listener and waits for the relay's `session_request` before opening:
```js
this.dappClient.once('session_request', (sessionRequest) => {
const deeplink = this.options.ui.factory.createConnectionDeeplink(connectionRequest);
const universalLink = this.options.ui.factory.createConnectionUniversalLink(connectionRequest);
openDeeplink(this.options, deeplink, universalLink);
});
return this.transport.connect({ scopes, … }).then(resolve)…
```
On a cold WS/TLS the awaited `transport.connect()` plus the `session_request` round-trip routinely takes 300-800 ms.
`openDeeplink` (`@metamask/connect-multichain/dist/src/multichain/utils/index.js`, ~line 67) resolves to `window.location.href = deeplink`. By the time Safari sees that assignment, the gesture is already gone — so the navigation falls back to host resolution.
### Why intermittent
Handshake latency varies. On a warm relay the 250 ms timer can still squeak past Safari on some iOS builds; on a cold relay (first-of-day, slow network, ad-block intercepting endpoints) it spills past the gesture window every time. That matches the "works sometimes, refresh and retry" symptom reported by our end users.
### Reproduction
1. Dapp using `@metamask/connect-solana@1.2.0` (or `@metamask/connect-evm` with the same underlying multichain client).
2. iOS Safari, MetaMask iOS app installed.
3. Tap a button whose `onClick` calls the Wallet Standard `connect()` feature.
4. On cold relay (Settings → Safari → Clear History; first connect of session), observe Safari navigates to `https://metamask.io` in the current tab instead of opening the MetaMask app.
5. Refresh, wait a few seconds, retry — usually succeeds.
### Suggested fix shape
The fundamental constraint is: **on iOS Safari, the `metamask://` navigation must run synchronously inside the user-gesture task.** That means either the SDK opens it sync, or it gives the dapp the URL to open sync.
Either of:
1. **Pre-allocate the session on `createSolanaClient`** so the connect URL is known without a relay round-trip. The dapp then calls a sync `connector.getConnectUrl()` and does `window.location.href = url` inside its own `onClick`. The existing async handshake continues in parallel; the MetaMask app, now foregrounded, completes the session via the relay.
2. **Have `connect()` return the URL synchronously before awaiting**, e.g. as a callback option or via the existing `mobile.preferredOpenLink` hook — but invoked *before* the handshake, with the URL ready. Today `preferredOpenLink` is only called inside the `session_request` listener, so this still misses the gesture window; moving the call earlier would fix it.
### Notes
- `mobile.preferredOpenLink` (around line 743) lets the dapp choose how to open the URL but doesn't help with the gesture-loss timing — by the time it's called, the gesture is already gone.
- A workaround dapps can apply today: gate the MetaMask row in their wallet picker on `registerMetaMaskConnect()` completion, so users can't tap before the SDK has finished initializing. That prevents one cold-start failure mode but **does not** fix the gesture-window race; the relay-handshake path still loses it.
Contributor guide
Research direction
Start with _deeplinkConnect in @metamask/connect-multichain/dist/src/multichain/index.js and openDeeplink in dist/src/multichain/utils/index.js, then trace the warm timer and cold session_request paths. Reproduce the cold and warm flows on iOS Safari; done means the metamask:// navigation occurs within the tap gesture rather than falling back to metamask.io.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, mobile-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100