refactor(native): isolate exact WVJB compatibility adapter

This commit is contained in:
2026-09-05 01:47:57 +08:00
parent 73cdf7d95c
commit a173d410e0
7 changed files with 542 additions and 302 deletions
@@ -0,0 +1,91 @@
/** External native handler names registered by the legacy H5 client. */
export const REGISTERED_NATIVE_HANDLER_NAMES = [
'getVideoinfo',
'sharelogin',
'gameui_play_voice',
'gameui_stop_voice',
'sharesuccess',
'getphoneinfo',
'getAddressBook',
'phonestate',
'appservice',
'getaudiourl',
'getBattery',
'getwifiLevel',
'getnetwork',
'shakeEnd',
'yPaytype',
'getlocationinfo',
'getWebdata',
'PayuserPaytypePaystate',
'setPostUrl',
'getphoto',
] as const;
/** External native methods called by the legacy H5 client. */
export const NATIVE_CALL_NAMES = [
'getphoto',
'h5Webpay',
'getVideoinfo',
'getphoneInfo',
'getAddressBook',
'createRoom',
'exitRoom',
'accreditlogin',
'cancellogin',
'OpenurlTitleData',
'getOther',
'voicePlaying',
'gameCopytext',
'opencamera',
'gamepastetext',
'getGameinstall',
'getGameplay',
'getphonestate',
'ypayType',
'SwitchOverGameData',
'backgameData',
'startlocation',
'getlocationinfo',
'srcIsloop',
'opensaoma',
'startshake',
'stopshake',
'SwitchShake',
'sharephotourl',
'browser',
'mediaTypeAudio',
'prepareaudio',
'vibrator',
'repeatvibrator',
'canclevibrator',
'orientation',
'notification',
'friendsSharetypeUrlToptitleDescript',
] as const;
export type RegisteredNativeHandlerName = (typeof REGISTERED_NATIVE_HANDLER_NAMES)[number];
export type NativeCallName = (typeof NATIVE_CALL_NAMES)[number];
export type NativeSettingName = 'agent' | 'gameconfig' | 'gameserver' | 'servertype';
/** Handler arguments remain opaque until a consuming feature defines its contract. */
export type NativeHandler = (...args: unknown[]) => unknown;
export type NativeResponseCallback = (...args: unknown[]) => void;
export interface NativeBridgeChannel {
registerHandler(name: string, handler: NativeHandler): void;
callHandler(name: string, data: unknown, callback?: NativeResponseCallback): void;
}
export interface NativeSettingsPort {
getOtherName(name: NativeSettingName): string | number;
getChannelName(): string | number;
getMarketName(): string | number;
}
export interface NativeBridgePort {
isReady(): boolean;
register(name: RegisteredNativeHandlerName, handler: NativeHandler): void;
call(name: NativeCallName, data: unknown, callback?: NativeResponseCallback): void;
dispose(): void;
}
@@ -0,0 +1,24 @@
import type { NativeSettingName, NativeSettingsPort } from './native-contracts.ts';
export interface NativeSettingsChannel {
getothername(name: NativeSettingName): string | number;
getchannelName(): string | number;
getmarketname(): string | number;
}
/** Exact-name adapter over the object injected at window.settings. */
export class NativeSettingsAdapter implements NativeSettingsPort {
constructor(private readonly settings: NativeSettingsChannel) {}
getOtherName(name: NativeSettingName): string | number {
return this.settings.getothername(name);
}
getChannelName(): string | number {
return this.settings.getchannelName();
}
getMarketName(): string | number {
return this.settings.getmarketname();
}
}
@@ -0,0 +1,133 @@
import {
NATIVE_CALL_NAMES,
REGISTERED_NATIVE_HANDLER_NAMES,
type NativeBridgeChannel,
type NativeBridgePort,
type NativeCallName,
type NativeHandler,
type NativeResponseCallback,
type RegisteredNativeHandlerName,
} from './native-contracts.ts';
export interface BridgeLoaderElement {
readonly tagName: string;
readonly style: { display: string };
src: string;
}
export interface WebViewJavascriptBridgeEnvironment {
WebViewJavascriptBridge?: NativeBridgeChannel;
WVJBCallbacks?: Array<(bridge: NativeBridgeChannel) => void>;
readonly document: {
addEventListener(name: string, listener: () => void, capture: boolean): void;
removeEventListener(name: string, listener: () => void, capture: boolean): void;
createElement(tagName: 'iframe'): BridgeLoaderElement;
readonly documentElement: {
appendChild(element: BridgeLoaderElement): void;
removeChild(element: BridgeLoaderElement): void;
};
};
setTimeout(callback: () => void, delay: 0): unknown;
}
export type DisposeBridgeSetup = () => void;
/**
* Reproduces the legacy WVJB bootstrap ordering, including continuing after
* an immediate bridge callback into the callback-queue/iframe block.
*/
export function setupWebViewJavascriptBridge(
environment: WebViewJavascriptBridgeEnvironment,
callback: (bridge: NativeBridgeChannel) => void,
): DisposeBridgeSetup {
let readyListener: (() => void) | undefined;
if (environment.WebViewJavascriptBridge) {
callback(environment.WebViewJavascriptBridge);
} else {
readyListener = () => callback(environment.WebViewJavascriptBridge as NativeBridgeChannel);
environment.document.addEventListener(
'WebViewJavascriptBridgeReady',
readyListener,
false,
);
}
if (environment.WVJBCallbacks) {
environment.WVJBCallbacks.push(callback);
} else {
environment.WVJBCallbacks = [callback];
const loader = environment.document.createElement('iframe');
loader.style.display = 'none';
loader.src = 'wvjbscheme://__BRIDGE_LOADED__';
environment.document.documentElement.appendChild(loader);
environment.setTimeout(() => environment.document.documentElement.removeChild(loader), 0);
}
return () => {
if (readyListener) {
environment.document.removeEventListener(
'WebViewJavascriptBridgeReady',
readyListener,
false,
);
}
const callbackIndex = environment.WVJBCallbacks?.indexOf(callback) ?? -1;
if (callbackIndex >= 0) environment.WVJBCallbacks?.splice(callbackIndex, 1);
};
}
function includesName<const Name extends string>(names: readonly Name[], name: string): name is Name {
return (names as readonly string[]).includes(name);
}
/** Per-instance facade over one WVJB channel. */
export class WebViewJavascriptBridgeAdapter implements NativeBridgePort {
private channel: NativeBridgeChannel | undefined;
private readonly handlers = new Map<RegisteredNativeHandlerName, NativeHandler>();
private disposed = false;
private readonly disposeSetup: DisposeBridgeSetup;
constructor(environment: WebViewJavascriptBridgeEnvironment) {
this.disposeSetup = setupWebViewJavascriptBridge(environment, channel => {
if (this.disposed || this.channel) return;
this.channel = channel;
for (const [name, handler] of this.handlers) channel.registerHandler(name, handler);
});
}
isReady(): boolean {
this.assertUsable();
return this.channel !== undefined;
}
register(name: RegisteredNativeHandlerName, handler: NativeHandler): void {
this.assertUsable();
if (!includesName(REGISTERED_NATIVE_HANDLER_NAMES, name)) {
throw new Error(`Unknown registered native handler: ${String(name)}`);
}
this.handlers.set(name, handler);
this.channel?.registerHandler(name, handler);
}
call(name: NativeCallName, data: unknown, callback?: NativeResponseCallback): void {
this.assertUsable();
if (!includesName(NATIVE_CALL_NAMES, name)) {
throw new Error(`Unknown native call: ${String(name)}`);
}
if (!this.channel) throw new Error('Native bridge is not ready');
this.channel.callHandler(name, data, callback);
}
dispose(): void {
if (this.disposed) return;
this.disposeSetup();
this.handlers.clear();
this.channel = undefined;
this.disposed = true;
}
private assertUsable(): void {
if (this.disposed) throw new Error('Native bridge adapter is disposed');
}
}