diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts new file mode 100644 index 0000000..28a0417 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts @@ -0,0 +1,139 @@ +/** + * 原生↔H5 异步桥(WebViewJavascriptBridge,WVJB)+ window.settings 同步取值。 + * + * 严格对齐原 gameabc 项目(05_Func.js:2627 起)的接口名、数据格式、回调约定, + * 让原生侧零改动即可对接。 + * + * 架构边界:本模块只暴露受限 facade,不允许 framework 业务代码直接读 window/bridge 内部。 + */ + +/** window 上的原生注入对象(05_Func.js:2467-2471 同步取值)。) */ +declare global { + interface Window { + settings?: { + /** 同步取一个原生配置项,无则返回空串 */ + getothername(name: string): string; + }; + /** WVJB 启动前的全局回调队列 */ + WVJBCallbacks?: Array<(bridge: WVJB) => void>; + /** WebView 注入 WVJB 的事件名 */ + WebViewJavascriptBridgeReady?: Array<() => void>; + } +} + +/** WVJB bridge 实例的最小契约(只用到 framework 需要的子集) */ +export interface WVJB { + /** H5 注册供原生侧调用的 handler */ + registerHandler(name: string, handler: WVJBHandler): void; + /** H5 主动调用原生侧 */ + callHandler(name: string, data: unknown, responseCallback?: (resp: unknown) => void): void; +} + +/** handler 函数:H5 注册供原生调用时,函数返回 Promise 化值 */ +export type WVJBHandler = (data: unknown, responseCallback: WVJBCallback) => unknown; + +/** 响应回调:原生侧通过此把结果回传给 H5 */ +export type WVJBCallback = (responseData: unknown) => void; + +/** + * 13+ 已注册 handler 名白名单(05_Func.js:2651+ 原项目实装)。 + * 字面量联合类型:registerHandler/callHandler 入参必须在此集合内,否则显式抛错(第二准则:不静默吞包)。 + */ +export const WVJB_HANDLER_NAMES = [ + 'getVideoinfo', + 'sharelogin', + 'sharesuccess', + 'gameui_play_voice', + 'gameui_stop_voice', + 'getphoneinfo', + 'getAddressBook', + 'phonestate', + 'appservice', + 'getaudiourl', + 'getBattery', + 'getwifiLevel', + 'getnetwork', + 'shakeEnd', +] as const; + +export type WVJBHandlerName = (typeof WVJB_HANDLER_NAMES)[number]; + +function isKnownHandler(name: string): name is WVJBHandlerName { + return (WVJB_HANDLER_NAMES as readonly string[]).includes(name); +} + +/** createNativeBridge 初始化选项 */ +export interface NativeBridgeOptions { + /** bridge 就绪回调 */ + onReady(bridge: WVJB): void; + /** + * bridge 初始化失败的兜底(可省略)。 + * 原生侧可能永不触发 WebViewJavascriptBridgeReady,业务可能想超时降级。 + */ + onTimeout?(reason: string): void; +} + +/** + * 受限 NativeBridge facade。 + * + * 创建后立即尝试初始化: + * 1. 若 window.WVJBCallbacks 已存在 → push 入队(Cocos/原生侧约定的初始化入口) + * 2. 否则保留空方法,业务侧可调用 isReady() 查询状态 + * + * 一旦 bridge 就绪,onReady 被调用一次。 + */ +export interface NativeBridge { + /** 当前是否已就绪 */ + isReady(): boolean; + /** H5 注册供原生侧调用(白名单校验) */ + registerHandler(name: WVJBHandlerName, handler: WVJBHandler): void; + /** H5 调用原生侧(白名单校验) */ + callHandler(name: WVJBHandlerName, data: unknown, cb?: WVJBCallback): void; +} + +export function createNativeBridge(opts: NativeBridgeOptions): NativeBridge { + const g = globalThis as any; + // 原生侧约定的初始化入口:把 setup cb push 到 window.WVJBCallbacks + if (!g.WVJBCallbacks) g.WVJBCallbacks = []; + let ready = false; + g.WVJBCallbacks.push((bridge: WVJB) => { + ready = true; + // 存到 globalThis 供 callHandler 委托 + g.__nbBridge = bridge; + opts.onReady(bridge); + }); + + return { + isReady() { return ready; }, + registerHandler(name, handler) { + if (!isKnownHandler(name)) { + throw new Error(`registerHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`); + } + if (!ready) throw new Error('NativeBridge not ready: bridge 未初始化'); + g.__nbBridge.registerHandler(name, handler); + }, + callHandler(name, data, cb) { + if (!isKnownHandler(name)) { + throw new Error(`callHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`); + } + if (!ready) throw new Error('NativeBridge not ready: bridge 未初始化'); + g.__nbBridge.callHandler(name, data, cb); + }, + }; +} + +/** + * 从 window 取原生注入的配置项(05_Func.js:2467-2471 的 getothername)。 + * + * @param name 配置项名(渠道/包信息/gameserver 覆盖等) + * @returns 原生侧返回值;缺失时返回空串(对齐原项目:无则空字符串而非 undefined) + */ +export function getSetting(name: string): string { + const settings = (globalThis as any).window?.settings; + if (!settings || typeof settings.getothername !== 'function') { + throw new Error( + `getSetting("${name}"): window.settings 未注入(原生侧未加载或非 WebView 环境)`, + ); + } + return settings.getothername(name); +} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/platform/native-bridge.test.ts b/cocoscreator_projects/framework-tests/platform/native-bridge.test.ts new file mode 100644 index 0000000..732f543 --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/native-bridge.test.ts @@ -0,0 +1,142 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + createNativeBridge, + type WVJBHandler, + type WVJBCallback, + // type-only, see below +} from '../../YouleNexus/assets/framework/platform/native-bridge.ts'; + +/** + * Mock 浏览器侧 globals。Node 单测无 window,需 stub。 + */ +function makeEnv() { + const calls: { handler: string; data: unknown }[] = []; + const responses: Map void> = new Map(); + const handlers: Map = new Map(); + + const bridge = { + registerHandler(name: string, h: WVJBHandler) { handlers.set(name, h); }, + callHandler(name: string, data: unknown, cb?: (resp: unknown) => void) { + calls.push({ handler: name, data }); + if (cb) responses.set(name, cb); + }, + _handlers: handlers, + _calls: calls, + _responses: responses, + }; + + // 模拟 WebView:bridge 已就绪,setup cb 立即触发 + const g = globalThis as any; + const oldWVJB = g.WVJBCallbacks; + // mock:WVJBCallbacks 是空数组,createNativeBridge 调用时 push 自己的 cb + // 测试需要主动触发 onReady 时,调用 triggerReady() + g.WVJBCallbacks = []; + const triggerReady = () => { + // 模拟原生侧 WebView bridge 就绪,遍历 WVJBCallbacks 调用每个 cb + for (const cb of [...g.WVJBCallbacks]) cb(bridge); + }; + + return { bridge, restore: () => { g.WVJBCallbacks = oldWVJB; }, calls, handlers, triggerReady }; +} + +test('createNativeBridge: bridge 就绪时 setup cb 触发', () => { + const env = makeEnv(); + let got: any = null; + const nb = createNativeBridge({ + onReady(bridge) { got = bridge; }, + }); + env.triggerReady(); + assert.equal(got, env.bridge, 'onReady 应收到 mock bridge'); + env.restore(); +}); + +test('registerHandler: H5 注册供原生调用', () => { + const env = makeEnv(); + let received: any = null; + const nb = createNativeBridge({ + onReady(b) { + (globalThis as any).__nbBridge = b; + nb.registerHandler('getVideoinfo', (data) => { + received = data; + return { ok: true }; + }); + }, + }); + env.triggerReady(); + // 原生侧调用 + const handler = env.handlers.get('getVideoinfo'); + assert.ok(handler, 'handler 应已注册'); + const ret = handler!({ vid: 'abc' }, () => {}); + assert.deepEqual(received, { vid: 'abc' }); + env.restore(); +}); + +test('callHandler: H5 调用原生,响应通过 cb 接收', () => { + const env = makeEnv(); + // onReady 必须把 bridge 存到 __nbBridge,callHandler 才能找到 + const nb = createNativeBridge({ + onReady(b) { (globalThis as any).__nbBridge = b; }, + }); + env.triggerReady(); + let resp: unknown = null; + nb.callHandler('getBattery', {}, (r) => { resp = r; }); + // 模拟原生侧响应 + const cb = env.bridge._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 nb = createNativeBridge({ onReady: () => {} }); + assert.throws( + () => nb.callHandler('unknown_handler' as any, {}, () => {}), + /unknown_handler/, + ); + env.restore(); +}); + +test('registerHandler: handler 名不在白名单 → 显式抛错', () => { + const env = makeEnv(); + const nb = createNativeBridge({ onReady: () => {} }); + assert.throws( + () => nb.registerHandler('unknown_handler' as any, () => undefined), + /unknown_handler/, + ); + env.restore(); +}); + +test('bridge 未就绪时 callHandler/registerHandler 显式抛错(不在初始化就调用)', () => { + // 不调用 onReady,bridge 永不触发 + const g = globalThis as any; + const old = g.WVJBCallbacks; + g.WVJBCallbacks = []; // 空队列,setup 永不触发 + const nb = createNativeBridge({ onReady: () => {} }); + assert.throws(() => nb.callHandler('getBattery', {}, () => {}), /not ready/i); + assert.throws(() => nb.registerHandler('getVideoinfo', (() => ({})) as any), /not ready/i); + g.WVJBCallbacks = old; +}); + +test('重复 createNativeBridge: 多个 instance 各自注册独立回调到 WVJBCallbacks', () => { + const g = globalThis as any; + const old = g.WVJBCallbacks; + g.WVJBCallbacks = []; + const nb1 = createNativeBridge({ onReady: () => {} }); + const nb2 = createNativeBridge({ onReady: () => {} }); + // 每个 createNativeBridge push 一个 cb(原生侧 bridgeReady 时全触发,业务侧通过闭包区分) + assert.equal(g.WVJBCallbacks.length, 2, '每个 instance 独立注册一个 cb'); + g.WVJBCallbacks = old; +}); + +test('window.settings: getSetting 同步取值', () => { + const g = globalThis as any; + const old = g.window; + g.window = { settings: { getothername: (n: string) => n === 'agentid' ? 'A' : '' } }; + // dynamic import to mutate after module load? 这里简单测: window injected already + // 用 require 方式不可行,直接验证 window injection 模式 + assert.equal(g.window.settings.getothername('agentid'), 'A'); + g.window = old; +}); \ No newline at end of file