diff --git a/apps/fxblox-web/src/components/setup/WalletSigner.tsx b/apps/fxblox-web/src/components/setup/WalletSigner.tsx index 7e95c0e..144c0c4 100644 --- a/apps/fxblox-web/src/components/setup/WalletSigner.tsx +++ b/apps/fxblox-web/src/components/setup/WalletSigner.tsx @@ -42,6 +42,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FxBox, FxButton, FxSpinner, FxText } from '@functionland/fx-ui'; +import { useLogger } from '@/hooks/useLogger'; import { useColorMode } from '@/stores/useSettingsStore'; import { getAppKit, setAppKitTheme } from '@/wallet/appkit'; import { diag } from '@/wallet/diag'; @@ -150,9 +151,12 @@ export default function WalletSigner({ const [requestLink, setRequestLink] = useState(null); const requestLinkRef = useRef(null); requestLinkRef.current = requestLink; + // Debug mode turns the automatic app-switch off — see the hop below for why that is a diagnostic, not a feature. + const { isDebugModeEnable: debug } = useLogger(); + const [hopSkippedForDebug, setHopSkippedForDebug] = useState(false); // Latest props / wallet state for the stable callbacks below (the mobile effects closed over stale state). - const latest = useRef({ password, onLinkingChange, onPhaseChange, onSignature, onError, wallet }); - latest.current = { password, onLinkingChange, onPhaseChange, onSignature, onError, wallet }; + const latest = useRef({ password, onLinkingChange, onPhaseChange, onSignature, onError, wallet, debug }); + latest.current = { password, onLinkingChange, onPhaseChange, onSignature, onError, wallet, debug }; useEffect(() => { setAppKitTheme(mode); @@ -199,6 +203,7 @@ export default function WalletSigner({ setShowNudge(false); setShowStuckHint(false); setWalletShowedNothing(false); + setHopSkippedForDebug(false); wentToWalletRef.current = false; return undefined; } @@ -322,6 +327,17 @@ export default function WalletSigner({ // remaining case is a navigation in a shape we did not recognise: the wallet is already in front, and // hopping again would bounce the user twice. if (capture.captured() || !capture.sawOpen()) { + // Debug mode: leave the wallet alone. Every retry so far went into a MetaMask that the FIRST hop had + // already wedged on its splash screen, so none of them tested whether the deep link itself is what + // wedges a healthy warm wallet — while the one recovery the logs show working was a cold MetaMask + // picking the request up with no deep link at all. With the request on the relay and no hop, the + // user can switch to the wallet by hand and see what it shows; the button is still there for the + // deep link afterwards. Debug mode is an explicit opt-in used for exactly this kind of report. + if (latest.current.debug) { + diag('[sign] debug mode: NOT opening the wallet — switch to it by hand, or tap the button'); + setHopSkippedForDebug(true); + return; + } diag(`[sign] opening the wallet on the request: ${link} activation=${userActivation()}`); wentToWalletRef.current = true; hopToWallet(link); @@ -402,6 +418,11 @@ export default function WalletSigner({ {t('setup.linkPassword.walletStuckHint')} )} + {hopSkippedForDebug && ( + + {t('setup.linkPassword.debugNoAutoOpen')} + + )} {showOpenWallet && walletLink && ( {t('setup.linkPassword.openWalletToApprove')} diff --git a/apps/fxblox-web/src/i18n/locales/en/setup.json b/apps/fxblox-web/src/i18n/locales/en/setup.json index 95e1eb7..1ee8522 100644 --- a/apps/fxblox-web/src/i18n/locales/en/setup.json +++ b/apps/fxblox-web/src/i18n/locales/en/setup.json @@ -59,7 +59,8 @@ "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 — 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" + "openWalletToApprove": "Open wallet to approve", + "debugNoAutoOpen": "Debug mode: the wallet was not opened automatically. The request is waiting on the relay — switch to your wallet from your recent apps and note what it shows, or tap Open wallet to approve." }, "connectToBlox": { "intro": "Turn your Blox on, then connect to it with Bluetooth. We'll suggest other ways if that doesn't work.", diff --git a/apps/fxblox-web/src/i18n/locales/zh/setup.json b/apps/fxblox-web/src/i18n/locales/zh/setup.json index 8cc7368..d519dcb 100644 --- a/apps/fxblox-web/src/i18n/locales/zh/setup.json +++ b/apps/fxblox-web/src/i18n/locales/zh/setup.json @@ -59,7 +59,8 @@ "walletConnectedTapSign": "钱包已连接。点击下方按钮进行签名 — 这会再次打开您的钱包。", "approveInWallet": "请在您的钱包中确认此请求…", "walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名” — 不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效。如果每次都这样,说明 Android 正在后台限制钱包:请在 设置 → 应用 → (应用)→ 电池 中,将 MetaMask 和您的浏览器设为“不受限制”,问题即可消除。", - "openWalletToApprove": "打开钱包进行签名" + "openWalletToApprove": "打开钱包进行签名", + "debugNoAutoOpen": "调试模式:未自动打开钱包。请求已在中继上等待 — 请从最近任务中切换到您的钱包并记录它显示的内容,或点击“打开钱包进行签名”。" }, "connectToBlox": { "intro": "打开您的 Blox,然后通过蓝牙连接。如果不成功,我们会为您提供其他方式。", 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 019386f..5764ed3 100644 --- a/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx +++ b/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx @@ -52,6 +52,7 @@ vi.mock('@/platform/linking', async (importOriginal) => { }); import { WALLET_NUDGE_MS, WALLET_STUCK_MS } from '@/components/setup/WalletSigner'; +import { useSettingsStore } from '@/stores/useSettingsStore'; import { useUserProfileStore } from '@/stores/useUserProfileStore'; import * as linking from '@/platform/linking'; import * as secureStore from '@/platform/secureStore'; @@ -455,6 +456,57 @@ describe('LinkPassword', () => { } }); + it('wallet path: in debug mode the request goes out but the wallet is NOT opened automatically', async () => { + // A diagnostic, not a feature. Every retry so far went into a wallet the first hop had already wedged, so + // nothing has tested whether the deep link itself wedges a healthy warm wallet. With the request on the + // relay and no hop, the user can switch to the wallet by hand and report what it shows; the button stays + // for the deep link afterwards. + const user = userEvent.setup(); + vi.mocked(linking.assign).mockClear(); + const listeners = new Set<(payload: unknown) => void>(); + let approve: (sig: string) => void = () => undefined; + wallet.state.account = '0xABC'; + wallet.state.connected = true; + wallet.state.provider = { + request: vi.fn(() => { + for (const fn of [...listeners]) + fn({ topic: 'topic-1', request: {}, chainId: 'eip155:1', id: 42 }); + return new Promise((resolve) => (approve = resolve)); + }), + session: { peer: { metadata: { redirect: { native: 'metamask://' } } } }, + client: { + on: (_event: string, fn: (payload: unknown) => void) => listeners.add(fn), + off: (_event: string, fn: (payload: unknown) => void) => listeners.delete(fn), + }, + } as never; + useSettingsStore.setState({ + debugMode: { uniqueId: 'dbg', endDate: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + try { + await renderSetupAt('/setup/link-password'); + await fillPasswordAndConsent(user); + await user.click(await screen.findByTestId('sign-with-wallet')); + + // Past the macrotask the hop is deferred by, so "not yet" cannot masquerade as "never". + expect(await screen.findByTestId('debug-no-auto-open')).toBeInTheDocument(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(linking.assign).not.toHaveBeenCalled(); + + // The deep link is still one tap away. + await user.click(screen.getByTestId('open-wallet')); + expect(linking.assign).toHaveBeenCalledWith('metamask://wc?requestId=42&sessionTopic=topic-1'); + + await act(async () => { + approve('0xlate'); + }); + await waitFor(() => expect(useUserProfileStore.getState().signiture).toBe('0xlate')); + } finally { + useSettingsStore.setState({ debugMode: undefined }); + } + }); + it('wallet path: a visibility change before any hop is not treated as a wedged wallet', async () => { // Tab switches happen for all sorts of reasons. Only a return from a wallet WE sent them to is evidence. const user = userEvent.setup();