diff --git a/apps/fxblox-web/src/components/setup/WalletSigner.tsx b/apps/fxblox-web/src/components/setup/WalletSigner.tsx index 9e5e101..b3a0001 100644 --- a/apps/fxblox-web/src/components/setup/WalletSigner.tsx +++ b/apps/fxblox-web/src/components/setup/WalletSigner.tsx @@ -43,7 +43,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FxBox, FxButton, FxSpinner, FxText } from '@functionland/fx-ui'; import { useColorMode } from '@/stores/useSettingsStore'; -import { setAppKitTheme } from '@/wallet/appkit'; +import { getAppKit, setAppKitTheme } from '@/wallet/appkit'; import { signChainCode } from '@/wallet/signChainCode'; import { connectedWalletLink } from '@/wallet/walletLink'; import { @@ -145,7 +145,23 @@ export default function WalletSigner({ // Coming back from the wallet lands on a socket Android killed while we were backgrounded. Reconnect it now // rather than waiting out the library's backoff, which is the several seconds of "connecting" a user sees // after they have already approved. - useRelayWake(wallet.provider); + // + // `wallet.provider` is only set once a session EXISTS (`ProviderController.setProvider` runs on connect), so + // for the connect round-trip itself — the first, and the one every user makes — it is undefined and the wake + // had nothing to act on. The socket that the approval has to arrive over lives on AppKit's UniversalProvider, + // which exists from the moment the chooser opens; ask AppKit for it directly. + const [universalProvider, setUniversalProvider] = useState(undefined); + useEffect(() => { + let alive = true; + void getAppKit() + ?.getUniversalProvider() + .then((p) => alive && setUniversalProvider(p)) + .catch(() => undefined); + return () => { + alive = false; + }; + }, []); + useRelayWake(wallet.provider ?? universalProvider); // The parent swaps the password field for a spinner while we are busy. `readyToSign` is NOT busy — the user // has to see the button to press it — so it deliberately does not count. diff --git a/apps/fxblox-web/src/i18n/locales/en/setup.json b/apps/fxblox-web/src/i18n/locales/en/setup.json index 49fa9a3..95e1eb7 100644 --- a/apps/fxblox-web/src/i18n/locales/en/setup.json +++ b/apps/fxblox-web/src/i18n/locales/en/setup.json @@ -58,7 +58,7 @@ "signaturePortalHint": "Sign at fxblox.fx.land, then paste the signature below together with the wallet address you signed with.", "walletConnectedTapSign": "Wallet connected. Tap below to approve the signature — this opens your wallet again.", "approveInWallet": "Approve the request in your wallet…", - "walletStuckHint": "Wallet opened but stuck on its splash screen? Close it completely from your recent apps, then tap Open wallet to approve again. That is the only thing that clears it — tapping again without closing the wallet first will not. Your request stays valid, so nothing is lost.", + "walletStuckHint": "Wallet opened but stuck on its splash screen? Close it completely from your recent apps, then tap Open wallet to approve again — tapping without closing it first will not help. Your request stays valid. If this happens every time, Android is restricting the wallet in the background: set MetaMask and your browser to Unrestricted under Settings → Apps → (app) → Battery, and it stops.", "openWalletToApprove": "Open wallet to approve" }, "connectToBlox": { diff --git a/apps/fxblox-web/src/i18n/locales/zh/setup.json b/apps/fxblox-web/src/i18n/locales/zh/setup.json index 087efd0..8cc7368 100644 --- a/apps/fxblox-web/src/i18n/locales/zh/setup.json +++ b/apps/fxblox-web/src/i18n/locales/zh/setup.json @@ -58,7 +58,7 @@ "signaturePortalHint": "请在 fxblox.fx.land 完成签名,然后将签名与用于签名的钱包地址一起粘贴到下方。", "walletConnectedTapSign": "钱包已连接。点击下方按钮进行签名 — 这会再次打开您的钱包。", "approveInWallet": "请在您的钱包中确认此请求…", - "walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名”。只有这样才能解决 — 不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效,不会丢失。", + "walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名” — 不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效。如果每次都这样,说明 Android 正在后台限制钱包:请在 设置 → 应用 → (应用)→ 电池 中,将 MetaMask 和您的浏览器设为“不受限制”,问题即可消除。", "openWalletToApprove": "打开钱包进行签名" }, "connectToBlox": { diff --git a/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx b/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx index a0bfaab..019386f 100644 --- a/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx +++ b/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx @@ -20,6 +20,8 @@ const wallet = vi.hoisted(() => ({ initAppKit: vi.fn(), setAppKitTheme: vi.fn(), disconnectWallet: vi.fn(async () => undefined), + // No AppKit instance in these tests: the relay wake falls back to `wallet.provider`, as it did before. + getAppKit: vi.fn(() => null), }, })); diff --git a/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts b/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts index 5f01147..531194a 100644 --- a/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts +++ b/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { useRelayWake, wakeRelay, WAKE_TIMEOUT_MS } from '../relayWake'; +import { BACKGROUND_STINT_MS, useRelayWake, wakeRelay, WAKE_TIMEOUT_MS } from '../relayWake'; interface RelayerState { connected?: boolean; @@ -54,9 +54,40 @@ describe('wakeRelay', () => { }); it('leaves a live socket alone', async () => { - const { provider, transportOpen } = providerWithRelayer({ connected: true }); + const { provider, transportOpen, restartTransport } = providerWithRelayer({ connected: true }); await wakeRelay(provider); expect(transportOpen).not.toHaveBeenCalled(); + expect(restartTransport).not.toHaveBeenCalled(); + }); + + it('does not believe "connected" after a background stint, and restarts the socket outright', async () => { + // `relayer.connected` is `socket.readyState === OPEN`, and Android suspends the TCP connection underneath + // a socket without telling it. Nothing in the library probes a browser socket, so a tab that comes back + // and trusts OPEN sits there while the wallet's approval waits on the relay — the "Connecting Wallet…" + // the user watches after they already approved. Restart, and let the resubscribe fetch what was queued. + const { provider, transportOpen, restartTransport } = providerWithRelayer({ connected: true }); + await wakeRelay(provider, { afterBackground: true }); + expect(restartTransport).toHaveBeenCalledTimes(1); + expect(transportOpen).not.toHaveBeenCalled(); + }); + + it('after a background stint, a socket that is honestly down takes the normal path', async () => { + const { provider, transportOpen, restartTransport } = providerWithRelayer({ opensSuccessfully: true }); + await wakeRelay(provider, { afterBackground: true }); + expect(transportOpen).toHaveBeenCalledTimes(1); + expect(restartTransport).not.toHaveBeenCalled(); + }); + + it('after a background stint, a relayer without restartTransport is left alone rather than broken', async () => { + const { provider, transportOpen } = providerWithRelayer({ connected: true, withRestart: false }); + await expect(wakeRelay(provider, { afterBackground: true })).resolves.toBeUndefined(); + expect(transportOpen).not.toHaveBeenCalled(); + }); + + it('a restart that fails after a background stint is swallowed too', async () => { + const { provider, restartTransport } = providerWithRelayer({ connected: true }); + restartTransport.mockRejectedValueOnce(new Error('relay unreachable')); + await expect(wakeRelay(provider, { afterBackground: true })).resolves.toBeUndefined(); }); it('still wakes when the relayer claims to be connecting', async () => { @@ -124,6 +155,43 @@ describe('useRelayWake', () => { expect(transportOpen).toHaveBeenCalledTimes(1); }); + it('restarts a socket that claims to be open when the tab was away long enough to have been in a wallet', () => { + vi.useFakeTimers(); + const { provider, restartTransport, transportOpen } = providerWithRelayer({ connected: true }); + renderHook(() => useRelayWake(provider)); + + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(BACKGROUND_STINT_MS + 500); + setVisibility('visible'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(restartTransport).toHaveBeenCalledTimes(1); + expect(transportOpen).not.toHaveBeenCalled(); + }); + + it('leaves an open socket alone after a mere flick between tabs', () => { + // A desktop socket was never suspended; restarting it on every tab switch would be a reconnect for nothing. + vi.useFakeTimers(); + const { provider, restartTransport } = providerWithRelayer({ connected: true }); + renderHook(() => useRelayWake(provider)); + + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(BACKGROUND_STINT_MS - 200); + setVisibility('visible'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(restartTransport).not.toHaveBeenCalled(); + }); + + it('a visible event with no preceding hidden is not treated as a return from the background', () => { + const { provider, restartTransport } = providerWithRelayer({ connected: true }); + renderHook(() => useRelayWake(provider)); + document.dispatchEvent(new Event('visibilitychange')); + expect(restartTransport).not.toHaveBeenCalled(); + }); + it('uses the current provider, not the one it mounted with', () => { // The provider arrives after the wallet connects, which is later than this hook mounts. const first = providerWithRelayer(); diff --git a/apps/fxblox-web/src/wallet/relayWake.ts b/apps/fxblox-web/src/wallet/relayWake.ts index 49887d2..077a32c 100644 --- a/apps/fxblox-web/src/wallet/relayWake.ts +++ b/apps/fxblox-web/src/wallet/relayWake.ts @@ -44,6 +44,27 @@ * session that will never reconnect on its own. `restartTransport()` already sequences the same teardown * correctly (`confirmOnlineStateOrThrow` → `resetTransport` → `transportOpen`), and `connect()` clears the * flag on the way in. Use the library's version. + * + * ## Why "connected" is not believed after a background stint + * + * All of the above only ever ran when `relayer.connected` was false. It usually is not. From the same bundle: + * + * get connected(){ return this.provider?.connection?.socket?.readyState === 1 || false } + * + * That is the WebSocket's own `readyState`, and Android suspends the TCP connection UNDERNEATH a socket + * without telling it. The socket keeps reporting OPEN — while the wallet's approval sits on the relay, waiting + * for a client that believes it needs nothing. Nothing in the library catches this from a browser: the + * ping-based liveness check (`startPingTimeout`) is gated on `isNode()`, because browser WebSockets expose no + * ping frames, and the heartbeat's own reconnect fires only on `!this.connected`. So the first thing to notice + * is Chrome's TCP stack, eventually, which is the several seconds of "Connecting Wallet…" a user watches + * after they have already approved. + * + * So when the tab comes back from a real stint in the background, `readyState` is not asked. The transport + * is restarted outright. `restartTransport()` tears the socket down, dials again, re-subscribes every topic, + * and its subscriber then calls `batchFetchMessages` — which is precisely the fetch of whatever the wallet + * published while we were dead. A socket that was in fact healthy pays one reconnect, well under a second on + * a working network; a socket that was not pays nothing more than it already owed. A tab hidden for less than + * `BACKGROUND_STINT_MS` is a flick between tabs, not a trip to a wallet, and is left alone. */ import { useEffect, useRef } from 'react'; @@ -56,6 +77,22 @@ import { useEffect, useRef } from 'react'; */ export const WAKE_TIMEOUT_MS = 2500; +/** + * How long the tab has to have been hidden before its socket is presumed dead on return. + * + * A trip to a wallet app and back is never shorter than this. A tab switch on a desktop routinely is, and a + * desktop socket was never suspended, so restarting it there would be a reconnect for nothing. + */ +export const BACKGROUND_STINT_MS = 1000; + +export interface WakeRelayOptions { + /** + * The tab is back from a real stint in the background. `relayer.connected` is then not trusted — see the + * file header — and a socket that claims to be open is restarted anyway. + */ + afterBackground?: boolean; +} + interface RelayerLike { /** True only when the underlying socket's readyState is OPEN. */ connected: boolean; @@ -96,22 +133,44 @@ const settleAfter = (ms: number): Promise => * has no relay). Never rejects: a relay that cannot be reached is not something the caller can act on, and the * next real request reports it properly, with the context of what the user was trying to do. */ -export async function wakeRelay(provider: unknown): Promise { +export async function wakeRelay(provider: unknown, opts: WakeRelayOptions = {}): Promise { const relayer = relayerFrom(provider); - if (!relayer || relayer.connected) return; + if (!relayer) return; + if (relayer.connected) { + // `readyState === OPEN` is the socket's opinion, and after a background stint it is not worth having — + // Android suspends the connection underneath it without a word. Restart rather than wait for Chrome's + // TCP stack to find out (file header). Off the background path a live socket is left alone as before. + if (!opts.afterBackground || typeof relayer.restartTransport !== 'function') return; + console.log('[relay] back from the background — restarting a socket that claims to be open'); + await relayer.restartTransport().catch(() => undefined); + return; + } // Bounded, because this call awaits any in-flight attempt — including one Android froze (see header). await Promise.race([relayer.transportOpen().catch(() => undefined), settleAfter(WAKE_TIMEOUT_MS)]); if (relayer.connected || typeof relayer.restartTransport !== 'function') return; await relayer.restartTransport().catch(() => undefined); } -/** Wake the socket every time this tab becomes visible again, for as long as the component is mounted. */ +/** + * Wake the socket every time this tab becomes visible again, for as long as the component is mounted. + * + * Remembers when the tab went hidden, so the return can tell a trip to the wallet (socket presumed dead, + * restart it) from a flick between tabs (leave a working socket alone). + */ export function useRelayWake(provider: unknown): void { const latest = useRef(provider); latest.current = provider; useEffect(() => { + let hiddenAt: number | null = null; const onVisibilityChange = () => { - if (document.visibilityState === 'visible') void wakeRelay(latest.current); + if (document.visibilityState === 'hidden') { + hiddenAt = Date.now(); + return; + } + if (document.visibilityState !== 'visible') return; + const afterBackground = hiddenAt !== null && Date.now() - hiddenAt >= BACKGROUND_STINT_MS; + hiddenAt = null; + void wakeRelay(latest.current, { afterBackground }); }; document.addEventListener('visibilitychange', onVisibilityChange); return () => document.removeEventListener('visibilitychange', onVisibilityChange);