Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions apps/fxblox-web/src/components/setup/WalletSigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<unknown>(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.
Expand Down
2 changes: 1 addition & 1 deletion apps/fxblox-web/src/i18n/locales/en/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/fxblox-web/src/i18n/locales/zh/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"signaturePortalHint": "请在 fxblox.fx.land 完成签名,然后将签名与用于签名的钱包地址一起粘贴到下方。",
"walletConnectedTapSign": "钱包已连接。点击下方按钮进行签名 — 这会再次打开您的钱包。",
"approveInWallet": "请在您的钱包中确认此请求…",
"walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名”。只有这样才能解决 — 不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效,不会丢失。",
"walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名” — 不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效。如果每次都这样,说明 Android 正在后台限制钱包:请在 设置 → 应用 → (应用)→ 电池 中,将 MetaMask 和您的浏览器设为“不受限制”,问题即可消除。",
"openWalletToApprove": "打开钱包进行签名"
},
"connectToBlox": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
}));

Expand Down
72 changes: 70 additions & 2 deletions apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
67 changes: 63 additions & 4 deletions apps/fxblox-web/src/wallet/relayWake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -96,22 +133,44 @@ const settleAfter = (ms: number): Promise<void> =>
* 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<void> {
export async function wakeRelay(provider: unknown, opts: WakeRelayOptions = {}): Promise<void> {
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);
Expand Down
Loading