From 756a84f56b9d4f4ef1392bc5aef166fbb1e04b9a Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 16 Feb 2026 10:31:14 +0800 Subject: [PATCH] feat: add android focus-blur guard and improve miniapp remark panel --- package.json | 1 + .../android-focus-blur-guard/package.json | 41 ++++++ .../src/index.test.ts | 68 +++++++++ .../android-focus-blur-guard/src/index.ts | 137 ++++++++++++++++++ .../android-focus-blur-guard/tsconfig.json | 16 ++ .../android-focus-blur-guard/vite.config.ts | 8 + packages/bio-sdk/package.json | 1 + packages/bio-sdk/src/index.ts | 3 + pnpm-lock.yaml | 20 ++- src/main.tsx | 3 + .../MiniappConfirmJobs.regression.test.tsx | 6 + .../sheets/MiniappTransferConfirmJob.tsx | 56 ++++++- 12 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 packages/android-focus-blur-guard/package.json create mode 100644 packages/android-focus-blur-guard/src/index.test.ts create mode 100644 packages/android-focus-blur-guard/src/index.ts create mode 100644 packages/android-focus-blur-guard/tsconfig.json create mode 100644 packages/android-focus-blur-guard/vite.config.ts diff --git a/package.json b/package.json index 6857e1d01..c1e38a6f2 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "miniapps:dev:biobridge": "pnpm --filter @biochain/miniapp-biobridge dev" }, "dependencies": { + "@biochain/android-focus-blur-guard": "workspace:*", "@base-ui/react": "^1.0.0", "@bfchain/util": "^5.0.0", "@bfmeta/sign-util": "^1.3.10", diff --git a/packages/android-focus-blur-guard/package.json b/packages/android-focus-blur-guard/package.json new file mode 100644 index 000000000..c92bc7e50 --- /dev/null +++ b/packages/android-focus-blur-guard/package.json @@ -0,0 +1,41 @@ +{ + "name": "@biochain/android-focus-blur-guard", + "version": "0.1.0", + "description": "Android focus/blur loop guard for WebView and iframe contexts", + "type": "module", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "files": [ + "src" + ], + "scripts": { + "build": "echo 'No build step required'", + "typecheck": "tsc --noEmit", + "typecheck:run": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run --passWithNoTests", + "test:storybook": "echo 'No storybook'", + "i18n:run": "echo 'No i18n'", + "theme:run": "echo 'No theme'" + }, + "devDependencies": { + "jsdom": "^27.2.0", + "typescript": "^5.9.3", + "vitest": "^4.0.0" + }, + "keywords": [ + "android", + "focus", + "blur", + "guard", + "webview" + ], + "license": "MIT" +} diff --git a/packages/android-focus-blur-guard/src/index.test.ts b/packages/android-focus-blur-guard/src/index.test.ts new file mode 100644 index 000000000..78a8fe690 --- /dev/null +++ b/packages/android-focus-blur-guard/src/index.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + installAndroidFocusBlurLoopGuard, + isAndroidUserAgent, + uninstallAndroidFocusBlurLoopGuard, +} from './index'; + +describe('android-focus-blur-guard', () => { + beforeEach(() => { + uninstallAndroidFocusBlurLoopGuard(); + }); + + it('detects android user agent', () => { + expect(isAndroidUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)')).toBe(true); + expect(isAndroidUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBe(false); + }); + + it('suppresses blur for body/html/iframe elements', () => { + const nativeSpy = vi.fn(); + const originalBlur = HTMLElement.prototype.blur; + HTMLElement.prototype.blur = function mockNativeBlur(this: HTMLElement): void { + nativeSpy(this.tagName); + }; + + const restore = installAndroidFocusBlurLoopGuard({ + isAndroid: () => true, + }); + + document.body.blur(); + document.documentElement.blur(); + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.blur(); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.blur(); + + expect(nativeSpy).toHaveBeenCalledTimes(1); + expect(nativeSpy).toHaveBeenCalledWith('INPUT'); + + restore(); + HTMLElement.prototype.blur = originalBlur; + }); + + it('blocks blur event propagation when active element is body-like', () => { + installAndroidFocusBlurLoopGuard({ + isAndroid: () => true, + }); + + const listener = vi.fn(); + window.addEventListener('blur', listener); + + window.dispatchEvent(new Event('blur')); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not install on non-android runtime', () => { + const nativeBlur = HTMLElement.prototype.blur; + const restore = installAndroidFocusBlurLoopGuard({ + isAndroid: () => false, + }); + + expect(HTMLElement.prototype.blur).toBe(nativeBlur); + restore(); + }); +}); diff --git a/packages/android-focus-blur-guard/src/index.ts b/packages/android-focus-blur-guard/src/index.ts new file mode 100644 index 000000000..8a633ea59 --- /dev/null +++ b/packages/android-focus-blur-guard/src/index.ts @@ -0,0 +1,137 @@ +export interface AndroidFocusBlurLoopGuardOptions { + /** + * Android 运行时检测(默认通过 UA 判断) + */ + isAndroid?: () => boolean; + /** + * 是否将 iframe 元素视为需拦截目标(默认 true) + */ + blockIframeElement?: boolean; +} + +type GuardState = { + restore: () => void; +}; + +type GuardWindow = Window & + typeof globalThis & { + __biochainAndroidFocusBlurLoopGuardState__?: GuardState; + }; + +const GUARD_KEY = '__biochainAndroidFocusBlurLoopGuardState__' as const; + +function isBlockedElement(element: Element | null, blockIframeElement: boolean): element is HTMLElement { + if (!(element instanceof HTMLElement)) { + return false; + } + + if (element === document.body || element === document.documentElement) { + return true; + } + + if (blockIframeElement && element instanceof HTMLIFrameElement) { + return true; + } + + return false; +} + +export function isAndroidUserAgent(userAgent: string): boolean { + return /android/i.test(userAgent); +} + +function isAndroidRuntime(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + + const nav = navigator as Navigator & { + userAgentData?: { + platform?: string; + }; + }; + + const platform = nav.userAgentData?.platform; + if (platform && /android/i.test(platform)) { + return true; + } + + return isAndroidUserAgent(nav.userAgent); +} + +/** + * 在 Android WebView 环境为全局 focus/blur 死循环做止血补丁。 + * - 拦截 window blur/focus 事件在 body/html/iframe 激活时的传播 + * - 禁止对 body/html/iframe 执行 blur() + * - 对 blur() 做最小重入保护 + */ +export function installAndroidFocusBlurLoopGuard( + options: AndroidFocusBlurLoopGuardOptions = {}, +): () => void { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return () => {}; + } + + const guardWindow = window as GuardWindow; + const existing = guardWindow[GUARD_KEY]; + if (existing) { + return existing.restore; + } + + const checkAndroid = options.isAndroid ?? isAndroidRuntime; + if (!checkAndroid()) { + return () => {}; + } + + const blockIframeElement = options.blockIframeElement ?? true; + const nativeBlur = HTMLElement.prototype.blur; + let blurring = false; + + const eventFirewall = (event: Event) => { + if (!isBlockedElement(document.activeElement, blockIframeElement)) { + return; + } + event.stopImmediatePropagation(); + event.stopPropagation(); + }; + + window.addEventListener('blur', eventFirewall, true); + window.addEventListener('focus', eventFirewall, true); + + HTMLElement.prototype.blur = function patchedBlur(this: HTMLElement): void { + if (isBlockedElement(this, blockIframeElement)) { + return; + } + + if (blurring) { + return; + } + + blurring = true; + try { + nativeBlur.call(this); + } finally { + queueMicrotask(() => { + blurring = false; + }); + } + }; + + const restore = () => { + window.removeEventListener('blur', eventFirewall, true); + window.removeEventListener('focus', eventFirewall, true); + HTMLElement.prototype.blur = nativeBlur; + delete guardWindow[GUARD_KEY]; + }; + + guardWindow[GUARD_KEY] = { restore }; + return restore; +} + +export function uninstallAndroidFocusBlurLoopGuard(): void { + if (typeof window === 'undefined') { + return; + } + const guardWindow = window as GuardWindow; + guardWindow[GUARD_KEY]?.restore(); +} diff --git a/packages/android-focus-blur-guard/tsconfig.json b/packages/android-focus-blur-guard/tsconfig.json new file mode 100644 index 000000000..23334c2f1 --- /dev/null +++ b/packages/android-focus-blur-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/packages/android-focus-blur-guard/vite.config.ts b/packages/android-focus-blur-guard/vite.config.ts new file mode 100644 index 000000000..c4588ab8c --- /dev/null +++ b/packages/android-focus-blur-guard/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + }, +}); diff --git a/packages/bio-sdk/package.json b/packages/bio-sdk/package.json index 7e51f88ca..f842fa7f8 100644 --- a/packages/bio-sdk/package.json +++ b/packages/bio-sdk/package.json @@ -31,6 +31,7 @@ "vitest": "^4.0.0" }, "dependencies": { + "@biochain/android-focus-blur-guard": "workspace:*", "zod": "^4.1.13" }, "keywords": [ diff --git a/packages/bio-sdk/src/index.ts b/packages/bio-sdk/src/index.ts index 373ec14e0..ef7bf6da7 100644 --- a/packages/bio-sdk/src/index.ts +++ b/packages/bio-sdk/src/index.ts @@ -25,6 +25,7 @@ import { BioProviderImpl } from './provider' import { EthereumProvider, initEthereumProvider } from './ethereum-provider' import { TronLinkProvider, TronWebProvider, initTronProvider } from './tron-provider' import type { BioProvider } from './types' +import { installAndroidFocusBlurLoopGuard } from '@biochain/android-focus-blur-guard' // Re-export types export * from './types' @@ -80,6 +81,8 @@ export function initAllProviders(targetOrigin = '*'): { // Auto-initialize if running in browser if (typeof window !== 'undefined') { + installAndroidFocusBlurLoopGuard() + const init = () => { initBioProvider() initEthereumProvider() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6016a2ed..ad9423da3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@bfmeta/sign-util': specifier: ^1.3.10 version: 1.3.10 + '@biochain/android-focus-blur-guard': + specifier: workspace:* + version: link:packages/android-focus-blur-guard '@biochain/bio-sdk': specifier: workspace:* version: link:packages/bio-sdk @@ -714,8 +717,23 @@ importers: specifier: ^4.0.0 version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + packages/android-focus-blur-guard: + devDependencies: + jsdom: + specifier: ^27.2.0 + version: 27.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + packages/bio-sdk: dependencies: + '@biochain/android-focus-blur-guard': + specifier: workspace:* + version: link:../android-focus-blur-guard zod: specifier: ^4.1.13 version: 4.2.1 @@ -12120,7 +12138,7 @@ snapshots: '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw diff --git a/src/main.tsx b/src/main.tsx index 17feff8ad..c1660b04a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,10 +2,13 @@ import './lib/error-capture' import './lib/superjson' import './polyfills' +import { installAndroidFocusBlurLoopGuard } from '@biochain/android-focus-blur-guard' import { startServiceMain } from './service-main' import { startFrontendMain } from './frontend-main' import { shouldBlockContextMenu } from './lib/context-menu-guard' +installAndroidFocusBlurLoopGuard() + // 禁用右键菜单(移动端 App 体验) document.addEventListener('contextmenu', (event) => { if (!shouldBlockContextMenu(event.target)) { diff --git a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx index 256ed7e05..453abfcb9 100644 --- a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx +++ b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx @@ -402,6 +402,12 @@ describe('miniapp confirm jobs regressions', () => { expect(confirmButton).not.toBeDisabled(); }); + expect(screen.getByTestId('miniapp-transfer-remark')).toBeInTheDocument(); + expect(screen.getByText('ex_type')).toBeInTheDocument(); + expect(screen.getByText('exchange.purchase')).toBeInTheDocument(); + expect(screen.getByText('ex_id')).toBeInTheDocument(); + expect(screen.getByText('exchange-001')).toBeInTheDocument(); + fireEvent.click(confirmButton); fireEvent.click(screen.getByTestId('pattern-lock')); diff --git a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx index 2199410cf..1b62d9ab6 100644 --- a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx +++ b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx @@ -160,6 +160,18 @@ function MiniappTransferConfirmJobContent() { () => (parsedAmount ? null : t('transaction:broadcast.invalidParams')), [parsedAmount, t], ); + const remarkEntries = useMemo(() => { + if (!remark) { + return [] as Array<{ key: string; value: string }>; + } + + return Object.entries(remark) + .map(([key, value]) => ({ + key: key.trim(), + value: value.trim(), + })) + .filter((entry) => entry.key.length > 0 && entry.value.length > 0); + }, [remark]); const transferShortTitle = useMemo( () => t('transaction:miniappTransfer.shortTitle', { amount: displayAmount, asset: displayAsset }), @@ -567,8 +579,8 @@ function MiniappTransferConfirmJobContent() { setSuccessCountdown(SUCCESS_CLOSE_SECONDS); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logTransferSheet('transfer.failed', { inputStep, message }); + const errorType = error instanceof Error ? error.name : typeof error; + logTransferSheet('transfer.failed', { inputStep, errorType }); if (inputStep === 'two_step_secret') { console.error('[miniapp-transfer][two-step-secret]', error); @@ -733,6 +745,26 @@ function MiniappTransferConfirmJobContent() { + {remarkEntries.length > 0 && ( +
+ {t('memo')} +
+ {remarkEntries.map((entry, index) => ( +
+ {entry.key} + {entry.value} +
+ ))} +
+
+ )} +

{t('transferWarning')}

@@ -900,6 +932,26 @@ function MiniappTransferConfirmJobContent() {
+ {remarkEntries.length > 0 && ( +
+ {t('memo')} +
+ {remarkEntries.map((entry, index) => ( +
+ {entry.key} + {entry.value} +
+ ))} +
+
+ )} + {isSuccess ? ( <>