refactor(native): isolate exact WVJB compatibility adapter
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
+24
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
/**
|
|
||||||
* 原生↔H5 异步桥(WebViewJavascriptBridge,WVJB)+ window.settings 同步取值。
|
|
||||||
*
|
|
||||||
* 严格对齐原 gameabc 项目(05_Func.js:2627 起)的外部契约:
|
|
||||||
* - 14 个 handler 名白名单(与原生 app 逐字对齐)
|
|
||||||
* - `window.WVJBCallbacks` / `WebViewJavascriptBridgeReady` 事件名
|
|
||||||
* - `window.settings.getothername(name)` 同步取值 API
|
|
||||||
* - 回调签名 `(data, responseCallback)` 与数据结构
|
|
||||||
*
|
|
||||||
* 内部命名(接口/函数/常量)走现代专业风格,与上述外部契约清晰隔离。
|
|
||||||
*
|
|
||||||
* 架构边界:本模块只暴露受限 facade,不允许 framework 业务代码直接读
|
|
||||||
* window/bridge 内部。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** window 上的原生注入对象(05_Func.js:2467-2471 同步取值)。 */
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
settings?: {
|
|
||||||
/** 同步取一个原生配置项,无则返回空串 */
|
|
||||||
getothername(name: string): string;
|
|
||||||
};
|
|
||||||
/** WVJB 启动前的全局回调队列(外部契约,不能改名) */
|
|
||||||
WVJBCallbacks?: Array<(bridge: NativeBridgeChannel) => void>;
|
|
||||||
/** WebView 注入 WVJB 的事件名(外部契约) */
|
|
||||||
WebViewJavascriptBridgeReady?: Array<() => void>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 原生桥通道契约 —— WVJB bridge 实例的最小子集。
|
|
||||||
* 内部命名为 "Channel" 以避免缩写、明确"双向通信通道"语义。
|
|
||||||
*/
|
|
||||||
export interface NativeBridgeChannel {
|
|
||||||
/** H5 注册供原生侧调用的 handler */
|
|
||||||
registerHandler(name: string, handler: NativeHandler): void;
|
|
||||||
/** H5 主动调用原生侧 */
|
|
||||||
callHandler(name: string, data: unknown, responseCallback?: (resp: unknown) => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** H5 注册供原生调用的 handler 函数签名(外部 WVJB 协议约定) */
|
|
||||||
export type NativeHandler = (data: unknown, responseCallback: NativeResponseCallback) => unknown;
|
|
||||||
|
|
||||||
/** 响应回调:原生侧通过此把结果回传给 H5 */
|
|
||||||
export type NativeResponseCallback = (responseData: unknown) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 14 个已注册 handler 名白名单(05_Func.js:2651+ 原项目实装)。
|
|
||||||
* 与原生 app 端逐字对齐 —— app 按这些名字调,改名 app 也要改。
|
|
||||||
*
|
|
||||||
* 字面量联合类型:registerHandler/callHandler 入参必须在此集合内,
|
|
||||||
* 拼写错误编译期就发现,集合外名字运行时显式抛错(第二准则:不静默兜底)。
|
|
||||||
*/
|
|
||||||
export const NATIVE_HANDLER_NAMES = [
|
|
||||||
'getVideoinfo',
|
|
||||||
'sharelogin',
|
|
||||||
'sharesuccess',
|
|
||||||
'gameui_play_voice',
|
|
||||||
'gameui_stop_voice',
|
|
||||||
'getphoneinfo',
|
|
||||||
'getAddressBook',
|
|
||||||
'phonestate',
|
|
||||||
'appservice',
|
|
||||||
'getaudiourl',
|
|
||||||
'getBattery',
|
|
||||||
'getwifiLevel',
|
|
||||||
'getnetwork',
|
|
||||||
'shakeEnd',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type NativeHandlerName = (typeof NATIVE_HANDLER_NAMES)[number];
|
|
||||||
|
|
||||||
function isNativeHandlerName(name: string): name is NativeHandlerName {
|
|
||||||
return (NATIVE_HANDLER_NAMES as readonly string[]).includes(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** createBridge 初始化选项 */
|
|
||||||
export interface BridgeOptions {
|
|
||||||
/** 通道就绪回调(原生侧 WebView bridgeReady 时触发一次) */
|
|
||||||
onReady(channel: NativeBridgeChannel): void;
|
|
||||||
/**
|
|
||||||
* 初始化超时的兜底(可省略)。
|
|
||||||
* 原生侧可能永不触发 WebViewJavascriptBridgeReady,业务可在此降级。
|
|
||||||
*/
|
|
||||||
onTimeout?(reason: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 受限 Bridge facade —— 框架业务访问原生能力的唯一入口。
|
|
||||||
*
|
|
||||||
* 创建后立即尝试初始化:
|
|
||||||
* 1. 把 setup 回调 push 到 window.WVJBCallbacks(原生侧约定的入口)
|
|
||||||
* 2. 原生侧 WebView bridgeReady 时遍历 WVJBCallbacks 调用每个 cb → onReady 被触发
|
|
||||||
*
|
|
||||||
* 一旦通道就绪,onReady 被调用一次。
|
|
||||||
*/
|
|
||||||
export interface Bridge {
|
|
||||||
/** 当前通道是否已就绪 */
|
|
||||||
isReady(): boolean;
|
|
||||||
/** H5 注册供原生侧调用(白名单校验) */
|
|
||||||
registerHandler(name: NativeHandlerName, handler: NativeHandler): void;
|
|
||||||
/** H5 调用原生侧(白名单校验) */
|
|
||||||
callHandler(name: NativeHandlerName, data: unknown, cb?: NativeResponseCallback): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBridge(opts: BridgeOptions): Bridge {
|
|
||||||
const g = globalThis as any;
|
|
||||||
// 外部契约:把 setup cb push 到 window.WVJBCallbacks
|
|
||||||
if (!g.WVJBCallbacks) g.WVJBCallbacks = [];
|
|
||||||
let ready = false;
|
|
||||||
g.WVJBCallbacks.push((channel: NativeBridgeChannel) => {
|
|
||||||
ready = true;
|
|
||||||
// 缓存通道供 callHandler 委托(避免 onReady 闭包外传)
|
|
||||||
g.__activeChannel = channel;
|
|
||||||
opts.onReady(channel);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isReady() { return ready; },
|
|
||||||
registerHandler(name, handler) {
|
|
||||||
if (!isNativeHandlerName(name)) {
|
|
||||||
throw new Error(`registerHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`);
|
|
||||||
}
|
|
||||||
if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调');
|
|
||||||
g.__activeChannel.registerHandler(name, handler);
|
|
||||||
},
|
|
||||||
callHandler(name, data, cb) {
|
|
||||||
if (!isNativeHandlerName(name)) {
|
|
||||||
throw new Error(`callHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`);
|
|
||||||
}
|
|
||||||
if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调');
|
|
||||||
g.__activeChannel.callHandler(name, data, cb);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 window 取原生注入的配置项(05_Func.js:2467-2471 的 getothername)。
|
|
||||||
*
|
|
||||||
* @param name 配置项名(渠道/包信息/gameserver 覆盖等)
|
|
||||||
* @returns 原生侧返回值;缺失时返回空串(对齐原项目:无则空字符串而非 undefined)
|
|
||||||
* @throws 原生侧未注入 window.settings 时显式抛错(不静默返回空串——避免下游误用)
|
|
||||||
*/
|
|
||||||
export function getNativeSetting(name: string): string {
|
|
||||||
const settings = (globalThis as any).window?.settings;
|
|
||||||
if (!settings || typeof settings.getothername !== 'function') {
|
|
||||||
throw new Error(
|
|
||||||
`getNativeSetting("${name}"): window.settings 未注入(原生侧未加载或非 WebView 环境)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return settings.getothername(name);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import { NativeSettingsAdapter } from '../../YouleNexus/assets/framework/adapters/native/native-settings-adapter.ts';
|
||||||
|
|
||||||
|
test('native settings calls the three original method spellings and preserves returns', () => {
|
||||||
|
const calls: unknown[][] = [];
|
||||||
|
const adapter = new NativeSettingsAdapter({
|
||||||
|
getothername(name) {
|
||||||
|
calls.push(['getothername', name]);
|
||||||
|
return name === 'agent' ? 0 : `value:${name}`;
|
||||||
|
},
|
||||||
|
getchannelName() {
|
||||||
|
calls.push(['getchannelName']);
|
||||||
|
return 'channel-a';
|
||||||
|
},
|
||||||
|
getmarketname() {
|
||||||
|
calls.push(['getmarketname']);
|
||||||
|
return 17;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(adapter.getOtherName('agent'), 0);
|
||||||
|
assert.equal(adapter.getOtherName('gameserver'), 'value:gameserver');
|
||||||
|
assert.equal(adapter.getChannelName(), 'channel-a');
|
||||||
|
assert.equal(adapter.getMarketName(), 17);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['getothername', 'agent'],
|
||||||
|
['getothername', 'gameserver'],
|
||||||
|
['getchannelName'],
|
||||||
|
['getmarketname'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NATIVE_CALL_NAMES,
|
||||||
|
REGISTERED_NATIVE_HANDLER_NAMES,
|
||||||
|
type NativeBridgeChannel,
|
||||||
|
type NativeHandler,
|
||||||
|
type NativeResponseCallback,
|
||||||
|
} from '../../YouleNexus/assets/framework/adapters/native/native-contracts.ts';
|
||||||
|
import {
|
||||||
|
WebViewJavascriptBridgeAdapter,
|
||||||
|
setupWebViewJavascriptBridge,
|
||||||
|
type BridgeLoaderElement,
|
||||||
|
type WebViewJavascriptBridgeEnvironment,
|
||||||
|
} from '../../YouleNexus/assets/framework/adapters/native/webview-javascript-bridge.ts';
|
||||||
|
|
||||||
|
const EXPECTED_REGISTERED_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;
|
||||||
|
|
||||||
|
const EXPECTED_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;
|
||||||
|
|
||||||
|
interface RecordingEnvironment extends WebViewJavascriptBridgeEnvironment {
|
||||||
|
created: BridgeLoaderElement[];
|
||||||
|
domOperations: string[];
|
||||||
|
events: Array<{ name: string; capture: boolean }>;
|
||||||
|
removedEvents: Array<{ name: string; capture: boolean }>;
|
||||||
|
runZeroDelayTimers(): void;
|
||||||
|
fireReady(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBridgeEnvironment(
|
||||||
|
initial: { bridge?: NativeBridgeChannel; callbacks?: Array<(bridge: NativeBridgeChannel) => void> } = {},
|
||||||
|
): RecordingEnvironment {
|
||||||
|
const created: BridgeLoaderElement[] = [];
|
||||||
|
const domOperations: string[] = [];
|
||||||
|
const events: Array<{ name: string; capture: boolean }> = [];
|
||||||
|
const removedEvents: Array<{ name: string; capture: boolean }> = [];
|
||||||
|
const listeners = new Map<string, Set<() => void>>();
|
||||||
|
const timers: Array<() => void> = [];
|
||||||
|
const environment: RecordingEnvironment = {
|
||||||
|
WebViewJavascriptBridge: initial.bridge,
|
||||||
|
WVJBCallbacks: initial.callbacks,
|
||||||
|
document: {
|
||||||
|
addEventListener(name, listener, capture) {
|
||||||
|
events.push({ name, capture });
|
||||||
|
const owned = listeners.get(name) ?? new Set<() => void>();
|
||||||
|
owned.add(listener);
|
||||||
|
listeners.set(name, owned);
|
||||||
|
},
|
||||||
|
removeEventListener(name, listener, capture) {
|
||||||
|
removedEvents.push({ name, capture });
|
||||||
|
listeners.get(name)?.delete(listener);
|
||||||
|
},
|
||||||
|
createElement(tagName) {
|
||||||
|
const element: BridgeLoaderElement = { tagName, style: { display: '' }, src: '' };
|
||||||
|
created.push(element);
|
||||||
|
return element;
|
||||||
|
},
|
||||||
|
documentElement: {
|
||||||
|
appendChild(element) { domOperations.push(`append:${element.tagName}`); },
|
||||||
|
removeChild(element) { domOperations.push(`remove:${element.tagName}`); },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
assert.equal(delay, 0);
|
||||||
|
timers.push(callback);
|
||||||
|
return timers.length;
|
||||||
|
},
|
||||||
|
created,
|
||||||
|
domOperations,
|
||||||
|
events,
|
||||||
|
removedEvents,
|
||||||
|
runZeroDelayTimers() {
|
||||||
|
for (const timer of timers.splice(0)) timer();
|
||||||
|
},
|
||||||
|
fireReady() {
|
||||||
|
for (const listener of [...(listeners.get('WebViewJavascriptBridgeReady') ?? [])]) listener();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecordingChannel() {
|
||||||
|
const registered: Array<{ name: string; handler: NativeHandler }> = [];
|
||||||
|
const called: Array<{ name: string; data: unknown; callback?: NativeResponseCallback }> = [];
|
||||||
|
const channel: NativeBridgeChannel = {
|
||||||
|
registerHandler(name, handler) { registered.push({ name, handler }); },
|
||||||
|
callHandler(name, data, callback) { called.push({ name, data, callback }); },
|
||||||
|
};
|
||||||
|
return { channel, registered, called };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('existing WebViewJavascriptBridge calls back immediately', () => {
|
||||||
|
const { channel: bridge } = makeRecordingChannel();
|
||||||
|
const env = makeBridgeEnvironment({ bridge });
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
setupWebViewJavascriptBridge(env, value => seen.push(value));
|
||||||
|
assert.deepEqual(seen, [bridge]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('immediate bridge branch still continues through the callback queue and iframe block', () => {
|
||||||
|
const { channel: bridge } = makeRecordingChannel();
|
||||||
|
const env = makeBridgeEnvironment({ bridge });
|
||||||
|
const callback = () => undefined;
|
||||||
|
setupWebViewJavascriptBridge(env, callback);
|
||||||
|
assert.deepEqual(env.WVJBCallbacks, [callback]);
|
||||||
|
assert.deepEqual(env.domOperations, ['append:iframe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without bridge registers the exact ready listener', () => {
|
||||||
|
const env = makeBridgeEnvironment();
|
||||||
|
setupWebViewJavascriptBridge(env, () => undefined);
|
||||||
|
assert.deepEqual(env.events, [{ name: 'WebViewJavascriptBridgeReady', capture: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ready listener reads the bridge from the environment at event time', () => {
|
||||||
|
const env = makeBridgeEnvironment();
|
||||||
|
const { channel: bridge } = makeRecordingChannel();
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
setupWebViewJavascriptBridge(env, value => seen.push(value));
|
||||||
|
env.WebViewJavascriptBridge = bridge;
|
||||||
|
env.fireReady();
|
||||||
|
assert.deepEqual(seen, [bridge]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first WVJB callback creates and removes the loader iframe', () => {
|
||||||
|
const env = makeBridgeEnvironment();
|
||||||
|
setupWebViewJavascriptBridge(env, () => undefined);
|
||||||
|
assert.equal(env.created[0].tagName, 'iframe');
|
||||||
|
assert.equal(env.created[0].style.display, 'none');
|
||||||
|
assert.equal(env.created[0].src, 'wvjbscheme://__BRIDGE_LOADED__');
|
||||||
|
assert.deepEqual(env.domOperations, ['append:iframe']);
|
||||||
|
env.runZeroDelayTimers();
|
||||||
|
assert.deepEqual(env.domOperations, ['append:iframe', 'remove:iframe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existing WVJB callback queue is appended without creating another iframe', () => {
|
||||||
|
const first = () => undefined;
|
||||||
|
const env = makeBridgeEnvironment({ callbacks: [first] });
|
||||||
|
const second = () => undefined;
|
||||||
|
setupWebViewJavascriptBridge(env, second);
|
||||||
|
assert.deepEqual(env.WVJBCallbacks, [first, second]);
|
||||||
|
assert.deepEqual(env.created, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registered handler names preserve the complete active original tuple', () => {
|
||||||
|
assert.deepEqual(REGISTERED_NATIVE_HANDLER_NAMES, EXPECTED_REGISTERED_NAMES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('called native method names preserve the complete active original tuple', () => {
|
||||||
|
assert.deepEqual(NATIVE_CALL_NAMES, EXPECTED_CALL_NAMES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('register preserves handler identity and every callback argument in order', () => {
|
||||||
|
const recording = makeRecordingChannel();
|
||||||
|
const env = makeBridgeEnvironment({ bridge: recording.channel });
|
||||||
|
const adapter = new WebViewJavascriptBridgeAdapter(env);
|
||||||
|
const seen: unknown[][] = [];
|
||||||
|
const handler: NativeHandler = (...args) => { seen.push(args); };
|
||||||
|
adapter.register('PayuserPaytypePaystate', handler);
|
||||||
|
|
||||||
|
assert.equal(recording.registered[0].name, 'PayuserPaytypePaystate');
|
||||||
|
assert.equal(recording.registered[0].handler, handler);
|
||||||
|
const responseCallback = () => undefined;
|
||||||
|
const args = [{ user: 1 }, 'type', 3, 'typeplay', { raw: true }, responseCallback] as const;
|
||||||
|
recording.registered[0].handler(...args);
|
||||||
|
assert.deepEqual(seen, [args]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call preserves payload and callback identity without changing callback arguments', () => {
|
||||||
|
const recording = makeRecordingChannel();
|
||||||
|
const env = makeBridgeEnvironment({ bridge: recording.channel });
|
||||||
|
const adapter = new WebViewJavascriptBridgeAdapter(env);
|
||||||
|
const payload = { exact: true };
|
||||||
|
const responses: unknown[][] = [];
|
||||||
|
const callback: NativeResponseCallback = (...args) => { responses.push(args); };
|
||||||
|
adapter.call('notification', payload, callback);
|
||||||
|
|
||||||
|
assert.deepEqual(recording.called.map(({ name }) => name), ['notification']);
|
||||||
|
assert.equal(recording.called[0].data, payload);
|
||||||
|
assert.equal(recording.called[0].callback, callback);
|
||||||
|
recording.called[0].callback?.('first', { second: true });
|
||||||
|
assert.deepEqual(responses, [['first', { second: true }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown register and call names fail explicitly', () => {
|
||||||
|
const recording = makeRecordingChannel();
|
||||||
|
const adapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment({ bridge: recording.channel }));
|
||||||
|
assert.throws(() => adapter.register('unknown' as never, () => undefined), /unknown/i);
|
||||||
|
assert.throws(() => adapter.call('unknown' as never, {}), /unknown/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registration queued before readiness runs once even if both original callbacks fire', () => {
|
||||||
|
const recording = makeRecordingChannel();
|
||||||
|
const env = makeBridgeEnvironment();
|
||||||
|
const adapter = new WebViewJavascriptBridgeAdapter(env);
|
||||||
|
adapter.register('getVideoinfo', () => undefined);
|
||||||
|
env.WebViewJavascriptBridge = recording.channel;
|
||||||
|
env.fireReady();
|
||||||
|
for (const callback of [...(env.WVJBCallbacks ?? [])]) callback(recording.channel);
|
||||||
|
assert.equal(adapter.isReady(), true);
|
||||||
|
assert.deepEqual(recording.registered.map(({ name }) => name), ['getVideoinfo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two adapter instances own independent channels without global active channel', () => {
|
||||||
|
const first = makeRecordingChannel();
|
||||||
|
const second = makeRecordingChannel();
|
||||||
|
const globalRecord = globalThis as typeof globalThis & { __activeChannel?: unknown };
|
||||||
|
const previous = globalRecord.__activeChannel;
|
||||||
|
delete globalRecord.__activeChannel;
|
||||||
|
try {
|
||||||
|
const firstAdapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment({ bridge: first.channel }));
|
||||||
|
const secondAdapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment({ bridge: second.channel }));
|
||||||
|
firstAdapter.call('browser', { owner: 'first' });
|
||||||
|
secondAdapter.call('browser', { owner: 'second' });
|
||||||
|
assert.deepEqual(first.called.map(({ data }) => data), [{ owner: 'first' }]);
|
||||||
|
assert.deepEqual(second.called.map(({ data }) => data), [{ owner: 'second' }]);
|
||||||
|
assert.equal('__activeChannel' in globalRecord, false);
|
||||||
|
} finally {
|
||||||
|
if (previous !== undefined) globalRecord.__activeChannel = previous;
|
||||||
|
else delete globalRecord.__activeChannel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose removes only its ready listener and rejects later use', () => {
|
||||||
|
const env = makeBridgeEnvironment();
|
||||||
|
const first = new WebViewJavascriptBridgeAdapter(env);
|
||||||
|
const second = new WebViewJavascriptBridgeAdapter(env);
|
||||||
|
first.dispose();
|
||||||
|
assert.deepEqual(env.removedEvents, [{ name: 'WebViewJavascriptBridgeReady', capture: false }]);
|
||||||
|
|
||||||
|
const recording = makeRecordingChannel();
|
||||||
|
env.WebViewJavascriptBridge = recording.channel;
|
||||||
|
env.fireReady();
|
||||||
|
assert.throws(() => first.isReady(), /disposed/i);
|
||||||
|
assert.equal(second.isReady(), true);
|
||||||
|
assert.throws(() => first.register('getVideoinfo', () => undefined), /disposed/i);
|
||||||
|
assert.throws(() => first.call('browser', ''), /disposed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call before readiness fails explicitly', () => {
|
||||||
|
const adapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment());
|
||||||
|
assert.throws(() => adapter.call('browser', ''), /not ready/i);
|
||||||
|
});
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import {
|
|
||||||
createBridge,
|
|
||||||
getNativeSetting,
|
|
||||||
type NativeHandler,
|
|
||||||
type NativeResponseCallback,
|
|
||||||
type NativeBridgeChannel,
|
|
||||||
} from '../../YouleNexus/assets/framework/platform/native-bridge.ts';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock 浏览器侧 globals。Node 单测无 window,需 stub。
|
|
||||||
*/
|
|
||||||
function makeEnv() {
|
|
||||||
const calls: { handler: string; data: unknown }[] = [];
|
|
||||||
const responses: Map<string, (data: any) => void> = new Map();
|
|
||||||
const handlers: Map<string, NativeHandler> = new Map();
|
|
||||||
|
|
||||||
const channel: NativeBridgeChannel = {
|
|
||||||
registerHandler(name, h) { handlers.set(name, h); },
|
|
||||||
callHandler(name, data, cb) {
|
|
||||||
calls.push({ handler: name, data });
|
|
||||||
if (cb) responses.set(name, cb);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const g = globalThis as any;
|
|
||||||
const oldWVJB = g.WVJBCallbacks;
|
|
||||||
g.WVJBCallbacks = [];
|
|
||||||
// mock 原生侧 WebView bridge 就绪:遍历 WVJBCallbacks 全调用
|
|
||||||
const triggerReady = () => {
|
|
||||||
for (const cb of [...g.WVJBCallbacks]) cb(channel);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
channel,
|
|
||||||
restore: () => { g.WVJBCallbacks = oldWVJB; },
|
|
||||||
calls,
|
|
||||||
handlers,
|
|
||||||
responses,
|
|
||||||
triggerReady,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('createBridge: 通道就绪时 onReady 触发', () => {
|
|
||||||
const env = makeEnv();
|
|
||||||
let got: any = null;
|
|
||||||
const bridge = createBridge({
|
|
||||||
onReady(c) { got = c; },
|
|
||||||
});
|
|
||||||
env.triggerReady();
|
|
||||||
assert.equal(got, env.channel, 'onReady 应收到 mock channel');
|
|
||||||
env.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerHandler: H5 注册供原生调用', () => {
|
|
||||||
const env = makeEnv();
|
|
||||||
let received: any = null;
|
|
||||||
const bridge = createBridge({
|
|
||||||
onReady(c) {
|
|
||||||
(globalThis as any).__activeChannel = c;
|
|
||||||
bridge.registerHandler('getVideoinfo', (data) => {
|
|
||||||
received = data;
|
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
env.triggerReady();
|
|
||||||
// 原生侧调用
|
|
||||||
const handler = env.handlers.get('getVideoinfo');
|
|
||||||
assert.ok(handler, 'handler 应已注册');
|
|
||||||
handler!({ vid: 'abc' }, (() => {}) as NativeResponseCallback);
|
|
||||||
assert.deepEqual(received, { vid: 'abc' });
|
|
||||||
env.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('callHandler: H5 调用原生,响应通过 cb 接收', () => {
|
|
||||||
const env = makeEnv();
|
|
||||||
const bridge = createBridge({
|
|
||||||
onReady(c) { (globalThis as any).__activeChannel = c; },
|
|
||||||
});
|
|
||||||
env.triggerReady();
|
|
||||||
let resp: unknown = null;
|
|
||||||
bridge.callHandler('getBattery', {}, (r) => { resp = r; });
|
|
||||||
// 模拟原生侧响应
|
|
||||||
const cb = env.responses.get('getBattery');
|
|
||||||
assert.ok(cb, 'callHandler 应记录 cb');
|
|
||||||
cb!({ level: 80, charging: false });
|
|
||||||
assert.deepEqual(resp, { level: 80, charging: false });
|
|
||||||
env.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('callHandler: handler 名不在白名单 → 显式抛错(第二准则)', () => {
|
|
||||||
const env = makeEnv();
|
|
||||||
const bridge = createBridge({ onReady: () => {} });
|
|
||||||
env.triggerReady();
|
|
||||||
assert.throws(
|
|
||||||
() => bridge.callHandler('unknown_handler' as any, {}, () => {}),
|
|
||||||
/unknown_handler/,
|
|
||||||
);
|
|
||||||
env.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerHandler: handler 名不在白名单 → 显式抛错', () => {
|
|
||||||
const env = makeEnv();
|
|
||||||
const bridge = createBridge({ onReady: () => {} });
|
|
||||||
env.triggerReady();
|
|
||||||
assert.throws(
|
|
||||||
() => bridge.registerHandler('unknown_handler' as any, () => undefined),
|
|
||||||
/unknown_handler/,
|
|
||||||
);
|
|
||||||
env.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bridge 未就绪时 callHandler/registerHandler 显式抛错', () => {
|
|
||||||
const g = globalThis as any;
|
|
||||||
const old = g.WVJBCallbacks;
|
|
||||||
g.WVJBCallbacks = []; // 空队列,triggerReady 不被调用
|
|
||||||
const bridge = createBridge({ onReady: () => {} });
|
|
||||||
assert.throws(() => bridge.callHandler('getBattery', {}, () => {}), /not ready/i);
|
|
||||||
assert.throws(() => bridge.registerHandler('getVideoinfo', (() => undefined) as any), /not ready/i);
|
|
||||||
g.WVJBCallbacks = old;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('重复 createBridge: 多个 instance 各自注册独立回调', () => {
|
|
||||||
const g = globalThis as any;
|
|
||||||
const old = g.WVJBCallbacks;
|
|
||||||
g.WVJBCallbacks = [];
|
|
||||||
createBridge({ onReady: () => {} });
|
|
||||||
createBridge({ onReady: () => {} });
|
|
||||||
assert.equal(g.WVJBCallbacks.length, 2, '每个 instance 独立注册一个 cb');
|
|
||||||
g.WVJBCallbacks = old;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getNativeSetting: 同步取 window.settings.getothername', () => {
|
|
||||||
const g = globalThis as any;
|
|
||||||
const oldWindow = g.window;
|
|
||||||
g.window = { settings: { getothername: (n: string) => n === 'agentid' ? 'A' : '' } };
|
|
||||||
assert.equal(getNativeSetting('agentid'), 'A');
|
|
||||||
assert.equal(getNativeSetting('missing'), '');
|
|
||||||
g.window = oldWindow;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getNativeSetting: window.settings 未注入 → 显式抛错(不静默兜底)', () => {
|
|
||||||
const g = globalThis as any;
|
|
||||||
const oldWindow = g.window;
|
|
||||||
g.window = {}; // 无 settings
|
|
||||||
assert.throws(() => getNativeSetting('agentid'), /window\.settings 未注入/);
|
|
||||||
g.window = oldWindow;
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user