Skip to content

Commit fb24da5

Browse files
authored
feat: add android focus-blur guard and improve miniapp remark panel (#460)
1 parent 5b3b8d8 commit fb24da5

12 files changed

Lines changed: 357 additions & 3 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"miniapps:dev:biobridge": "pnpm --filter @biochain/miniapp-biobridge dev"
7272
},
7373
"dependencies": {
74+
"@biochain/android-focus-blur-guard": "workspace:*",
7475
"@base-ui/react": "^1.0.0",
7576
"@bfchain/util": "^5.0.0",
7677
"@bfmeta/sign-util": "^1.3.10",
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "@biochain/android-focus-blur-guard",
3+
"version": "0.1.0",
4+
"description": "Android focus/blur loop guard for WebView and iframe contexts",
5+
"type": "module",
6+
"main": "./src/index.ts",
7+
"module": "./src/index.ts",
8+
"types": "./src/index.ts",
9+
"exports": {
10+
".": {
11+
"import": "./src/index.ts",
12+
"types": "./src/index.ts"
13+
}
14+
},
15+
"files": [
16+
"src"
17+
],
18+
"scripts": {
19+
"build": "echo 'No build step required'",
20+
"typecheck": "tsc --noEmit",
21+
"typecheck:run": "tsc --noEmit",
22+
"test": "vitest",
23+
"test:run": "vitest run --passWithNoTests",
24+
"test:storybook": "echo 'No storybook'",
25+
"i18n:run": "echo 'No i18n'",
26+
"theme:run": "echo 'No theme'"
27+
},
28+
"devDependencies": {
29+
"jsdom": "^27.2.0",
30+
"typescript": "^5.9.3",
31+
"vitest": "^4.0.0"
32+
},
33+
"keywords": [
34+
"android",
35+
"focus",
36+
"blur",
37+
"guard",
38+
"webview"
39+
],
40+
"license": "MIT"
41+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import {
3+
installAndroidFocusBlurLoopGuard,
4+
isAndroidUserAgent,
5+
uninstallAndroidFocusBlurLoopGuard,
6+
} from './index';
7+
8+
describe('android-focus-blur-guard', () => {
9+
beforeEach(() => {
10+
uninstallAndroidFocusBlurLoopGuard();
11+
});
12+
13+
it('detects android user agent', () => {
14+
expect(isAndroidUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)')).toBe(true);
15+
expect(isAndroidUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBe(false);
16+
});
17+
18+
it('suppresses blur for body/html/iframe elements', () => {
19+
const nativeSpy = vi.fn();
20+
const originalBlur = HTMLElement.prototype.blur;
21+
HTMLElement.prototype.blur = function mockNativeBlur(this: HTMLElement): void {
22+
nativeSpy(this.tagName);
23+
};
24+
25+
const restore = installAndroidFocusBlurLoopGuard({
26+
isAndroid: () => true,
27+
});
28+
29+
document.body.blur();
30+
document.documentElement.blur();
31+
const iframe = document.createElement('iframe');
32+
document.body.appendChild(iframe);
33+
iframe.blur();
34+
35+
const input = document.createElement('input');
36+
document.body.appendChild(input);
37+
input.blur();
38+
39+
expect(nativeSpy).toHaveBeenCalledTimes(1);
40+
expect(nativeSpy).toHaveBeenCalledWith('INPUT');
41+
42+
restore();
43+
HTMLElement.prototype.blur = originalBlur;
44+
});
45+
46+
it('blocks blur event propagation when active element is body-like', () => {
47+
installAndroidFocusBlurLoopGuard({
48+
isAndroid: () => true,
49+
});
50+
51+
const listener = vi.fn();
52+
window.addEventListener('blur', listener);
53+
54+
window.dispatchEvent(new Event('blur'));
55+
56+
expect(listener).not.toHaveBeenCalled();
57+
});
58+
59+
it('does not install on non-android runtime', () => {
60+
const nativeBlur = HTMLElement.prototype.blur;
61+
const restore = installAndroidFocusBlurLoopGuard({
62+
isAndroid: () => false,
63+
});
64+
65+
expect(HTMLElement.prototype.blur).toBe(nativeBlur);
66+
restore();
67+
});
68+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
export interface AndroidFocusBlurLoopGuardOptions {
2+
/**
3+
* Android 运行时检测(默认通过 UA 判断)
4+
*/
5+
isAndroid?: () => boolean;
6+
/**
7+
* 是否将 iframe 元素视为需拦截目标(默认 true)
8+
*/
9+
blockIframeElement?: boolean;
10+
}
11+
12+
type GuardState = {
13+
restore: () => void;
14+
};
15+
16+
type GuardWindow = Window &
17+
typeof globalThis & {
18+
__biochainAndroidFocusBlurLoopGuardState__?: GuardState;
19+
};
20+
21+
const GUARD_KEY = '__biochainAndroidFocusBlurLoopGuardState__' as const;
22+
23+
function isBlockedElement(element: Element | null, blockIframeElement: boolean): element is HTMLElement {
24+
if (!(element instanceof HTMLElement)) {
25+
return false;
26+
}
27+
28+
if (element === document.body || element === document.documentElement) {
29+
return true;
30+
}
31+
32+
if (blockIframeElement && element instanceof HTMLIFrameElement) {
33+
return true;
34+
}
35+
36+
return false;
37+
}
38+
39+
export function isAndroidUserAgent(userAgent: string): boolean {
40+
return /android/i.test(userAgent);
41+
}
42+
43+
function isAndroidRuntime(): boolean {
44+
if (typeof navigator === 'undefined') {
45+
return false;
46+
}
47+
48+
const nav = navigator as Navigator & {
49+
userAgentData?: {
50+
platform?: string;
51+
};
52+
};
53+
54+
const platform = nav.userAgentData?.platform;
55+
if (platform && /android/i.test(platform)) {
56+
return true;
57+
}
58+
59+
return isAndroidUserAgent(nav.userAgent);
60+
}
61+
62+
/**
63+
* 在 Android WebView 环境为全局 focus/blur 死循环做止血补丁。
64+
* - 拦截 window blur/focus 事件在 body/html/iframe 激活时的传播
65+
* - 禁止对 body/html/iframe 执行 blur()
66+
* - 对 blur() 做最小重入保护
67+
*/
68+
export function installAndroidFocusBlurLoopGuard(
69+
options: AndroidFocusBlurLoopGuardOptions = {},
70+
): () => void {
71+
if (typeof window === 'undefined' || typeof document === 'undefined') {
72+
return () => {};
73+
}
74+
75+
const guardWindow = window as GuardWindow;
76+
const existing = guardWindow[GUARD_KEY];
77+
if (existing) {
78+
return existing.restore;
79+
}
80+
81+
const checkAndroid = options.isAndroid ?? isAndroidRuntime;
82+
if (!checkAndroid()) {
83+
return () => {};
84+
}
85+
86+
const blockIframeElement = options.blockIframeElement ?? true;
87+
const nativeBlur = HTMLElement.prototype.blur;
88+
let blurring = false;
89+
90+
const eventFirewall = (event: Event) => {
91+
if (!isBlockedElement(document.activeElement, blockIframeElement)) {
92+
return;
93+
}
94+
event.stopImmediatePropagation();
95+
event.stopPropagation();
96+
};
97+
98+
window.addEventListener('blur', eventFirewall, true);
99+
window.addEventListener('focus', eventFirewall, true);
100+
101+
HTMLElement.prototype.blur = function patchedBlur(this: HTMLElement): void {
102+
if (isBlockedElement(this, blockIframeElement)) {
103+
return;
104+
}
105+
106+
if (blurring) {
107+
return;
108+
}
109+
110+
blurring = true;
111+
try {
112+
nativeBlur.call(this);
113+
} finally {
114+
queueMicrotask(() => {
115+
blurring = false;
116+
});
117+
}
118+
};
119+
120+
const restore = () => {
121+
window.removeEventListener('blur', eventFirewall, true);
122+
window.removeEventListener('focus', eventFirewall, true);
123+
HTMLElement.prototype.blur = nativeBlur;
124+
delete guardWindow[GUARD_KEY];
125+
};
126+
127+
guardWindow[GUARD_KEY] = { restore };
128+
return restore;
129+
}
130+
131+
export function uninstallAndroidFocusBlurLoopGuard(): void {
132+
if (typeof window === 'undefined') {
133+
return;
134+
}
135+
const guardWindow = window as GuardWindow;
136+
guardWindow[GUARD_KEY]?.restore();
137+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
5+
"module": "ESNext",
6+
"moduleResolution": "bundler",
7+
"strict": true,
8+
"noEmit": true,
9+
"esModuleInterop": true,
10+
"skipLibCheck": true,
11+
"declaration": true,
12+
"declarationMap": true,
13+
"isolatedModules": true
14+
},
15+
"include": ["src"]
16+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
environment: 'jsdom',
6+
globals: true,
7+
},
8+
});

packages/bio-sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"vitest": "^4.0.0"
3232
},
3333
"dependencies": {
34+
"@biochain/android-focus-blur-guard": "workspace:*",
3435
"zod": "^4.1.13"
3536
},
3637
"keywords": [

packages/bio-sdk/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { BioProviderImpl } from './provider'
2525
import { EthereumProvider, initEthereumProvider } from './ethereum-provider'
2626
import { TronLinkProvider, TronWebProvider, initTronProvider } from './tron-provider'
2727
import type { BioProvider } from './types'
28+
import { installAndroidFocusBlurLoopGuard } from '@biochain/android-focus-blur-guard'
2829

2930
// Re-export types
3031
export * from './types'
@@ -80,6 +81,8 @@ export function initAllProviders(targetOrigin = '*'): {
8081

8182
// Auto-initialize if running in browser
8283
if (typeof window !== 'undefined') {
84+
installAndroidFocusBlurLoopGuard()
85+
8386
const init = () => {
8487
initBioProvider()
8588
initEthereumProvider()

pnpm-lock.yaml

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
import './lib/error-capture'
33
import './lib/superjson'
44
import './polyfills'
5+
import { installAndroidFocusBlurLoopGuard } from '@biochain/android-focus-blur-guard'
56
import { startServiceMain } from './service-main'
67
import { startFrontendMain } from './frontend-main'
78
import { shouldBlockContextMenu } from './lib/context-menu-guard'
89

10+
installAndroidFocusBlurLoopGuard()
11+
912
// 禁用右键菜单(移动端 App 体验)
1013
document.addEventListener('contextmenu', (event) => {
1114
if (!shouldBlockContextMenu(event.target)) {

0 commit comments

Comments
 (0)