diff --git a/common/Index.ets b/common/Index.ets index b3e28f4..a893158 100644 --- a/common/Index.ets +++ b/common/Index.ets @@ -4,6 +4,8 @@ export { Logger, LOG_DOMAIN } from './src/main/ets/log/Logger'; export { BridgeTracer } from './src/main/ets/log/BridgeTracer'; export { EventBus, EventPayload } from './src/main/ets/event/EventBus'; +export { NavEvents, OpenGenericWebPayload, SwitchGamePayload, BackGamePayload } + from './src/main/ets/event/NavEvents'; export { DIContainer, Factory } from './src/main/ets/di/DIContainer'; export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter'; export { AppEnv } from './src/main/ets/env/AppEnv'; diff --git a/common/src/main/ets/event/EventBus.ets b/common/src/main/ets/event/EventBus.ets index 30ac6bd..cbc0ac1 100644 --- a/common/src/main/ets/event/EventBus.ets +++ b/common/src/main/ets/event/EventBus.ets @@ -7,14 +7,14 @@ */ import { emitter } from '@kit.BasicServicesKit'; -/** 事件载荷:键值对,值为 Object(需可跨线程时只放 Sendable/基本类型)。 */ -export type EventPayload = Record; +/** 事件载荷:基类型 Object(ArkTS 严格禁 Record 字面量;传具名 interface 实例,订阅侧 as 还原)。 */ +export type EventPayload = Object; export class EventBus { /** 持续订阅。返回取消函数——调用它仅注销本次订阅(per-subscriber,不影响其他订阅者)。 * 多容器/多订阅者共享同一 eventId(如 appservice 前后台)时务必用返回值退订,勿用 off(eventId)。 */ static on(eventId: string, callback: (payload?: EventPayload) => void): () => void { - const wrapper = (ev: emitter.EventData) => { + const wrapper = (ev: emitter.GenericEventData) => { callback(ev.data); }; emitter.on(eventId, wrapper); @@ -25,7 +25,7 @@ export class EventBus { /** 单次订阅,触发后自动取消。返回取消函数(触发前可主动退订)。 */ static once(eventId: string, callback: (payload?: EventPayload) => void): () => void { - const wrapper = (ev: emitter.EventData) => { + const wrapper = (ev: emitter.GenericEventData) => { callback(ev.data); }; emitter.once(eventId, wrapper); @@ -34,9 +34,9 @@ export class EventBus { }; } - /** 发布事件。 */ + /** 发布事件(payload 为具名 interface 实例或省略)。 */ static emit(eventId: string, payload?: EventPayload): void { - const data: emitter.EventData = { data: payload }; + const data: emitter.GenericEventData = { data: payload }; emitter.emit(eventId, data); } diff --git a/common/src/main/ets/event/NavEvents.ets b/common/src/main/ets/event/NavEvents.ets new file mode 100644 index 0000000..5c6f0dc --- /dev/null +++ b/common/src/main/ets/event/NavEvents.ets @@ -0,0 +1,37 @@ +/** + * 导航事件名 + 载荷(跨模块共享:NavProvider(feature_capabilities) 发 → BridgeGameContainer(entry) 订阅执行)。 + * + * 容器持有 pathStack/WebviewController,能力层不直接导航——经 EventBus 解耦。 + * 载荷用具名 interface(ArkTS 严格:禁 Record/无类型对象字面量)。 + */ +export class NavEvents { + /** 打开通用网页容器。 */ + static readonly OPEN_GENERIC_WEB: string = 'nav.openGenericWeb'; + /** 容器内切换子游戏。 */ + static readonly SWITCH_GAME: string = 'nav.switchGame'; + /** 子游戏带数据返回大厅。 */ + static readonly BACK_GAME: string = 'nav.backGame'; +} + +/** OPEN_GENERIC_WEB 载荷(§11.2)。 */ +export interface OpenGenericWebPayload { + url: string; + title: string; + data: string; + /** "1" 竖屏 / 其他 横屏 */ + orientation: string; +} + +/** SWITCH_GAME 载荷(§11.1)。 */ +export interface SwitchGamePayload { + /** "2" 竖屏 / "3" 横屏 */ + webtype: string; + /** 目标游戏目录名 */ + dir: string; + data: string; +} + +/** BACK_GAME 载荷。 */ +export interface BackGamePayload { + data: string; +} diff --git a/domain_resource/src/main/ets/resource/AppDataInjector.ets b/domain_resource/src/main/ets/resource/AppDataInjector.ets index 282c5dc..101d6c4 100644 --- a/domain_resource/src/main/ets/resource/AppDataInjector.ets +++ b/domain_resource/src/main/ets/resource/AppDataInjector.ets @@ -9,6 +9,7 @@ */ import { FileSystem } from 'platform'; import { Logger } from 'common'; +import { AppConfig } from '../config/AppConfig'; /** app_data.js 全局变量取值(§6)。 */ export interface AppDataValues { @@ -41,6 +42,25 @@ export interface AppDataValues { export class AppDataInjector { private static readonly log: Logger = Logger.tag('AppDataInjector'); + /** 由本地配置 + 资源版本 + 启动类型构建 app_data 值(大厅 launchtype='0' / 子游戏 '1')。 */ + static buildValues(config: AppConfig, version: string, launchtype: string): AppDataValues { + return { + app_version: version, + app_gameconfig: config.gameconfig, + app_gamedir: config.gamedir, + app_gamestart: config.gamestart, + app_agent: config.agent, + app_appversion: config.appversion, + app_market: config.market, + app_channel: config.channel, + app_invitationcode: config.tuiguang, + app_Launchtype: launchtype, + // TODO(M3):app_gamename 源为 version.xml 的 ,当前 VersionXml 仅解析 version。 + app_gamename: '', + app_getwifisignalLevel: '0', + }; + } + /** 生成 app_data.js 文本(一组全局 var 声明)。 */ static buildJs(v: AppDataValues): string { const lines: string[] = [ diff --git a/domain_resource/src/main/ets/startup/StartupOrchestrator.ets b/domain_resource/src/main/ets/startup/StartupOrchestrator.ets index 88022d0..23ad63f 100644 --- a/domain_resource/src/main/ets/startup/StartupOrchestrator.ets +++ b/domain_resource/src/main/ets/startup/StartupOrchestrator.ets @@ -120,24 +120,9 @@ export class StartupOrchestrator { return { enter: false, entryUrl: '', message: '资源准备失败,请检查网络后重试' }; } - // 6) 注入 app_data.js(务必在进大厅之前) + // 6) 注入 app_data.js(务必在进大厅之前)。大厅 launchtype='0' this.stage(StartupStage.INJECT_APP_DATA, 90); - const values: AppDataValues = { - app_version: `${resource.localVersion()}`, - app_gameconfig: local.gameconfig, - app_gamedir: local.gamedir, - app_gamestart: local.gamestart, - app_agent: local.agent, - app_appversion: local.appversion, - app_market: local.market, - app_channel: local.channel, - app_invitationcode: local.tuiguang, - app_Launchtype: '0', - // TODO(M3):app_gamename 源为 version.xml 的 ,当前 VersionXml 仅解析 version; - // H5 若读 app_gamename 暂为空串。需要时扩展 VersionXml 用 token 回调按标签上下文取 name 属性。 - app_gamename: '', - app_getwifisignalLevel: '0', - }; + const values: AppDataValues = AppDataInjector.buildValues(local, `${resource.localVersion()}`, '0'); AppDataInjector.inject(resource.paths().gameDir, values); // 7) 进大厅 diff --git a/entry/src/main/ets/di/AppModule.ets b/entry/src/main/ets/di/AppModule.ets index b9ea1f2..dcc8513 100644 --- a/entry/src/main/ets/di/AppModule.ets +++ b/entry/src/main/ets/di/AppModule.ets @@ -8,7 +8,7 @@ import { CapabilityProvider, EchoSampleProvider, RoomStubProvider, PayStubProvider, VibrateProvider, ClipboardProvider, AppSystemProvider, DeviceProvider, NetworkProvider, ShareProvider, LoginProvider, PhotoProvider, ShakeProvider, - ScanProvider, CameraProvider, LocationProvider, AudioProvider } from 'feature_capabilities'; + ScanProvider, CameraProvider, LocationProvider, AudioProvider, NavProvider } from 'feature_capabilities'; export function buildCapabilities(): CapabilityProvider[] { return [ @@ -27,6 +27,7 @@ export function buildCapabilities(): CapabilityProvider[] { new CameraProvider(), // opencamera → getcameraaAddress new LocationProvider(), // startlocation/getlocationinfo → getlocationinfo new AudioProvider(), // mediaTypeAudio/srcIsloop/voicePlaying(播放);prepareaudio 录音分档 + new NavProvider(), // OpenurlTitleData/SwitchOverGameData/getGameinstall/backgameData // —— 暂缓能力占位桩(§6.5)—— new RoomStubProvider(), new PayStubProvider(), diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index 7b9ee35..b34f045 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -1,19 +1,21 @@ import { webview } from '@kit.ArkWeb'; import { common } from '@kit.AbilityKit'; +import { window } from '@kit.ArkUI'; +import { BusinessError } from '@kit.BasicServicesKit'; import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge'; import { CapabilityContext, CapabilityRegistrar } from 'feature_capabilities'; -import { ConfigManager, ResourceManager } from 'domain_resource'; +import { ConfigManager, ResourceManager, AppDataInjector, AppDataValues } from 'domain_resource'; import { KvStore, LocalUploadServer } from 'platform'; import { OutboundHandlers } from 'contracts'; -import { AppEnv, EventBus, Logger } from 'common'; -import { BridgeGameParams, AppEvents } from '../routes/AppRoutes'; +import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload, + BackGamePayload, Logger } from 'common'; +import { BridgeGameParams, GenericWebParams, GenericWebResult, RouteName, AppEvents } from '../routes/AppRoutes'; import { buildCapabilities } from '../di/AppModule'; /** * 大厅/子游戏容器(对应 webviewActivity,框架 §7.1)。 * 挂 Web + 接桥 + 经组装根注册全部能力 + 首次进度 100% 推 appservice/setPostUrl。 - * 入口 URL 由 StartupOrchestrator 经 params.entryUrl 传入(file://.../index.html?Launchtype=0); - * 为空时回退最小回显页(V1 验证用)。 + * 子游戏切换(路径 A)/打开通用容器(路径 B)/返回大厅 经 NavEvents 由 NavProvider 触发、本容器执行。 */ @Component export struct BridgeGameContainer { @@ -24,64 +26,48 @@ export struct BridgeGameContainer { private bridge: BridgeController | undefined = undefined; private registrar: CapabilityRegistrar | undefined = undefined; private uploadServer: LocalUploadServer = new LocalUploadServer(); + private hostCtx: common.UIAbilityContext | undefined = undefined; + private config: ConfigManager | undefined = undefined; + private resource: ResourceManager | undefined = undefined; private firstProgressDone: boolean = false; - private cancelForeground: (() => void) | undefined = undefined; - private cancelBackground: (() => void) | undefined = undefined; + private pendingWebdata: string | undefined = undefined; + private cancels: Array<() => void> = []; aboutToAppear(): void { // 远程调试仅 debug 包开启,release 自动关闭(CLAUDE.md 附录 B 红线) if (AppEnv.isDebug()) { webview.WebviewController.setWebDebuggingAccess(true); } - // 前后台订阅移到 setupBridge(桥就位后),避免冷启动 FOREGROUND 早于桥到达被静默丢弃 - } - - /** 前后台联动订阅:EntryAbility 经 EventBus 广播 → 转给各能力 + callHandler('appservice')。 */ - private subscribeLifecycle(): void { - this.cancelForeground = EventBus.on(AppEvents.FOREGROUND, () => { - this.registrar?.forEachForeground(); - this.bridge?.callHandler(OutboundHandlers.AppService, '1'); - }); - this.cancelBackground = EventBus.on(AppEvents.BACKGROUND, () => { - this.registrar?.forEachBackground(); - this.bridge?.callHandler(OutboundHandlers.AppService, '2'); - }); + // 事件订阅移到 setupBridge(桥就位后),避免冷启动事件早于桥到达被丢 } aboutToDisappear(): void { - // 释放顺序:能力 onDestroy → 清桥(回执表/注册表/启动队列)→ 断 UI 通道 → 退订事件 → 置空 this.registrar?.destroyAll(); this.bridge?.dispose(); this.adapter?.dispose(); - if (this.cancelForeground !== undefined) { - this.cancelForeground(); - } - if (this.cancelBackground !== undefined) { - this.cancelBackground(); + for (const cancel of this.cancels) { + cancel(); } + this.cancels = []; this.uploadServer.stop(); this.registrar = undefined; this.bridge = undefined; this.adapter = undefined; } - /** onControllerAttached:建桥 + 经组装根注册全部能力(框架 §A.1:能力注册放这里)。 */ + /** onControllerAttached:建桥 + 经组装根注册全部能力(框架 §A.1)。 */ private setupBridge(): void { const hostCtx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; + this.hostCtx = hostCtx; - // 🔴 V2 file:// 跨域(框架 §7.3):file:// 页面的 XHR/fetch 默认被 CORS 拦截(origin 'null')。 - // 官方方案:把资源根加入"允许跨域访问"白名单,file:// 访问该路径下资源即放开同源限制。 - // 对 H5 完全透明(仍 file:// 加载)。路径须在 filesDir 子目录且与用户文件隔离。 - // 跨域白名单路径取自 ResourceManager(单一来源,避免与领域层路径定义漂移致 V2 回归) + // 🔴 V2 file:// 跨域(框架 §7.3):资源根加入"允许跨域访问"白名单(单一来源取自 ResourceManager) try { this.controller.setPathAllowingUniversalAccess([ResourceManager.resourceRoot(hostCtx)]); } catch (e) { - const err = e as Error; - Logger.tag('BridgeGameContainer').e(`setPathAllowingUniversalAccess failed: ${err.message}`); + Logger.tag('BridgeGameContainer').e(`setPathAllowingUniversalAccess failed: ${(e as Error).message}`); } - // 🔴 先建桥并就位:即使下面能力注册/配置加载抛错,桥仍可用——未注册 handler 落 DefaultHandler, - // H5 调用不报错(铁律最后防线)。桥失效才是真正违反"H5 调用永不报错"。 + // 🔴 先建桥并就位:能力注册/配置加载抛错也不致桥失效(未注册 handler 落 DefaultHandler,铁律兜底) const adapter = new WebviewControllerAdapter(this.controller); const bridge = new BridgeController(adapter); this.adapter = adapter; @@ -89,10 +75,14 @@ export struct BridgeGameContainer { try { bridge.setBridgeJs(BridgeJsLoader.load(hostCtx)); + const config: ConfigManager = ConfigManager.load(hostCtx); + const kv: KvStore = KvStore.create(hostCtx); + this.config = config; + this.resource = new ResourceManager(hostCtx, config.getLocal(), kv); const ctx: CapabilityContext = { uiAbilityContext: hostCtx, - config: ConfigManager.load(hostCtx), - kv: KvStore.create(hostCtx), + config, + kv, uploadServer: this.uploadServer, log: Logger.tag('Capability'), }; @@ -100,14 +90,84 @@ export struct BridgeGameContainer { registrar.registerAll(bridge, ctx); this.registrar = registrar; } catch (e) { - const err = e as Error; - Logger.tag('BridgeGameContainer').e(`capability registration failed (bridge still usable): ${err.message}`); + Logger.tag('BridgeGameContainer').e(`capability registration failed (bridge still usable): ${(e as Error).message}`); } - this.subscribeLifecycle(); + this.subscribeEvents(); this.uploadServer.start(); } + /** 订阅前后台 + 导航事件(桥就位后)。 */ + private subscribeEvents(): void { + this.cancels.push(EventBus.on(AppEvents.FOREGROUND, () => { + this.registrar?.forEachForeground(); + this.bridge?.callHandler(OutboundHandlers.AppService, '1'); + })); + this.cancels.push(EventBus.on(AppEvents.BACKGROUND, () => { + this.registrar?.forEachBackground(); + this.bridge?.callHandler(OutboundHandlers.AppService, '2'); + })); + this.cancels.push(EventBus.on(NavEvents.OPEN_GENERIC_WEB, (p?: EventPayload) => this.openGenericWeb(p))); + this.cancels.push(EventBus.on(NavEvents.SWITCH_GAME, (p?: EventPayload) => this.switchGame(p))); + this.cancels.push(EventBus.on(NavEvents.BACK_GAME, (p?: EventPayload) => this.backToLobby(p))); + } + + /** 路径 B:打开通用网页容器;关闭回传 → 上层 callHandler('getWebdata')。 */ + private openGenericWeb(p?: EventPayload): void { + if (p === undefined) { + return; + } + const d = p as OpenGenericWebPayload; + const params: GenericWebParams = { url: d.url, title: d.title, data: d.data, orientation: d.orientation }; + this.pathStack.pushPathByName(RouteName.GENERIC_WEB, params, (info: PopInfo) => { + const result = info.result as GenericWebResult; + this.bridge?.callHandler(OutboundHandlers.GetWebData, result !== undefined ? result.data : ''); + }, false); + } + + /** 路径 A:同容器切换子游戏(loadUrl + 重注入 app_data + 方向 + 复位首屏标志)。 */ + private switchGame(p?: EventPayload): void { + if (p === undefined) { + return; + } + const d = p as SwitchGamePayload; + const dir: string = d.dir; + const webtype: string = d.webtype; + const res = this.resource; + const cfg = this.config; + if (dir === '' || res === undefined || cfg === undefined) { + return; + } + const subDir: string = `${res.paths().urlpath}/${dir}`; + // 子游戏目录需自带 app_data.js(H5 同步读),launchtype='1' + const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), `${res.localVersion()}`, '1'); + AppDataInjector.inject(subDir, values); + this.firstProgressDone = false; // 复位:切换后重推 appservice/setPostUrl + this.applyOrientation(webtype !== '2'); // "3" 横 / "2" 竖 + this.controller.loadUrl(`file://${subDir}/index.html?Launchtype=1`); + } + + /** 子游戏带数据返回大厅:重载大厅入口,加载完成后出站 getWebdata。 */ + private backToLobby(p?: EventPayload): void { + if (this.params.entryUrl === '' || p === undefined) { + return; + } + this.pendingWebdata = (p as BackGamePayload).data; + this.firstProgressDone = false; + this.applyOrientation(true); // 大厅横屏 + this.controller.loadUrl(this.params.entryUrl); + } + + private applyOrientation(landscape: boolean): void { + const ctx = this.hostCtx; + if (ctx === undefined) { + return; + } + const target: window.Orientation = landscape ? window.Orientation.LANDSCAPE : window.Orientation.PORTRAIT; + window.getLastWindow(ctx).then((win: window.Window) => win.setPreferredOrientation(target)) + .catch((e: BusinessError) => Logger.tag('BridgeGameContainer').w(`orientation failed: ${e.message}`)); + } + /** 入口 URL:params 非空用之,否则回退最小回显页(V1)。 */ private webSrc(): string | Resource { return this.params.entryUrl !== '' ? this.params.entryUrl : $rawfile('test_echo.html'); @@ -137,17 +197,19 @@ export struct BridgeGameContainer { this.bridge?.onPageEnd(); }) .onProgressChange((event: OnProgressChangeEvent) => { - // 首次进度 100% → 推 appservice(前台) + setPostUrl(与 Android onProgressChanged==100 一致)。 - // TODO(M3 T-M3-06 子游戏切换):SwitchOverGameData 走同容器 controller.loadUrl 后页面会再次到 100%, - // 需在 loadUrl 前重置 firstProgressDone 并重注入 app_data.js,使切换后的子游戏页同样收到 setPostUrl。 - // 届时新增 reloadGame(entryUrl) 入口由 NavProvider 调用;当前 M2 仅首屏,故一次性标志足够。 if (event.newProgress === 100 && !this.firstProgressDone) { + // 首次/每次切换后页面到 100% → 推 appservice(前台) + setPostUrl(对齐 Android onProgressChanged==100) this.firstProgressDone = true; const b = this.bridge; if (b !== undefined) { b.callHandler(OutboundHandlers.AppService, '1'); b.callHandler(OutboundHandlers.SetPostUrl, this.uploadServer.baseUrl()); } + // 子游戏返回大厅的回传数据,待大厅重载完成后下发 getWebdata + if (this.pendingWebdata !== undefined) { + b?.callHandler(OutboundHandlers.GetWebData, this.pendingWebdata); + this.pendingWebdata = undefined; + } } }) } diff --git a/entry/src/main/ets/pages/GenericWebContainer.ets b/entry/src/main/ets/pages/GenericWebContainer.ets index 44087a8..864209f 100644 --- a/entry/src/main/ets/pages/GenericWebContainer.ets +++ b/entry/src/main/ets/pages/GenericWebContainer.ets @@ -1,37 +1,119 @@ +import { webview } from '@kit.ArkWeb'; +import { common, Want } from '@kit.AbilityKit'; +import { window } from '@kit.ArkUI'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { SettingsProxy } from 'feature_bridge'; +import { Logger } from 'common'; import { GenericWebParams, GenericWebResult } from '../routes/AppRoutes'; /** - * 通用网页容器壳(对应 openwebActivity1,框架 §7.2)。 - * M0:仅路由壳,演示 backgameData(data) → pop 回传上层(结果码 101 语义)。 - * M4:填入 Web 组件 + SettingsProxy(settings 6 方法 + getWebdata/gamebackkeydown/backgameData 直调)。 + * 通用网页容器(对应 openwebActivity1,框架 §7.2 / 契约 §11.3)。**第二套机制,不挂桥**: + * - javaScriptProxy 注入 settings 对象(6 方法)。 + * - 首次进度 100% 用 runJavaScript 直调 getWebdata('')。 + * - 返回键:isbackfinishweb→gamebackkeydown / isexitdialogeshow→退出确认→backgameData()。 + * - 关闭以结果码 101 语义 pop({data}) 回上层 → 上层 callHandler('getWebdata')。 */ @Component export struct GenericWebContainer { pathStack: NavPathStack = new NavPathStack(); params: GenericWebParams = { url: '', title: '', data: '', orientation: '0' }; + private controller: webview.WebviewController = new webview.WebviewController(); + private settings: SettingsProxy = new SettingsProxy( + (_u: string) => { }, (_u: string) => { }, (_d: string | undefined) => { }, + (_o: boolean) => { }, (_o: boolean) => { }); + private loaded: boolean = false; + private exitDialog: boolean = false; + private backToH5: boolean = false; - /** 模拟 settings.backgameData(data):带数据返回上层(§11.3)。 */ - private backWithData(): void { - const result: GenericWebResult = { data: `from-web:${this.params.data}` }; + aboutToAppear(): void { + // settings 6 方法委托容器能力(§11.3) + this.settings = new SettingsProxy( + (url: string) => { if (url !== '') { this.controller.loadUrl(url); } }, + (url: string) => this.openBrowser(url), + (returnData: string | undefined) => this.finish(returnData), + (on: boolean) => { this.exitDialog = on; }, + (on: boolean) => { this.backToH5 = on; }, + ); + // 方向:orientation "1" 竖屏 / 其他 横屏(§11.2) + this.applyOrientation(this.params.orientation === '1'); + } + + /** 关闭并以 101 语义把 data 回传上层(上层 onPop → callHandler getWebdata)。 */ + private finish(returnData: string | undefined): void { + const result: GenericWebResult = { data: returnData ?? '' }; this.pathStack.pop(result, false); } + private openBrowser(url: string): void { + if (url === '') { + return; + } + const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext; + const want: Want = { action: 'ohos.want.action.viewData', entities: ['entity.system.browsable'], uri: url }; + ctx.startAbility(want).catch((e: BusinessError) => + Logger.tag('GenericWebContainer').w(`openBrowser failed: ${e.code} ${e.message}`)); + } + + private applyOrientation(portrait: boolean): void { + const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext; + const target: window.Orientation = portrait ? window.Orientation.PORTRAIT : window.Orientation.LANDSCAPE; + window.getLastWindow(ctx).then((win: window.Window) => win.setPreferredOrientation(target)) + .catch((e: BusinessError) => Logger.tag('GenericWebContainer').w(`orientation failed: ${e.message}`)); + } + + /** JS 字符串字面量转义(用于 runJavaScript 直调)。 */ + private esc(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + } + build() { NavDestination() { - Column({ space: 16 }) { - Text('GenericWebContainer 壳') - .fontSize(20) - .fontWeight(FontWeight.Bold) - Text(`title=${this.params.title} data=${this.params.data}`) - .fontSize(14) - .fontColor(Color.Gray) - Button('backgameData 返回(带数据)') - .onClick(() => this.backWithData()) - } - .width('100%') - .height('100%') - .justifyContent(FlexAlign.Center) + Web({ src: this.params.url, controller: this.controller }) + .javaScriptAccess(true) + .domStorageAccess(true) + .fileAccess(true) + .geolocationAccess(true) + .zoomAccess(false) + .cacheMode(CacheMode.Default) + .javaScriptProxy({ + object: this.settings, + name: 'settings', + methodList: ['backgameData', 'loadurl', 'browser', 'finishweb', 'isexitdialogeshow', 'isbackfinishweb'], + controller: this.controller, + }) + .width('100%') + .height('100%') + .onProgressChange((event: OnProgressChangeEvent) => { + // 首次 100% → 原生直调 getWebdata('')(§11.3) + if (event.newProgress === 100 && !this.loaded) { + this.loaded = true; + this.controller.runJavaScript(`getWebdata('${this.esc(this.params.data)}')`); + } + }) } .hideTitleBar(true) + .onBackPressed(() => { + // 返回键逻辑(§11.3):H5 接管 → gamebackkeydown;退出确认 → backgameData();否则关闭 + if (this.backToH5) { + this.controller.runJavaScript('gamebackkeydown()'); + return true; + } + if (this.exitDialog) { + AlertDialog.show({ + message: '确定退出当前页面?', + primaryButton: { + value: '确定', + action: () => { + this.controller.runJavaScript('backgameData()'); + this.finish(undefined); + }, + }, + secondaryButton: { value: '取消', action: () => { } }, + }); + return true; + } + this.finish(undefined); + return true; + }) } } diff --git a/feature_bridge/Index.ets b/feature_bridge/Index.ets index 899128c..1fbbfb5 100644 --- a/feature_bridge/Index.ets +++ b/feature_bridge/Index.ets @@ -11,3 +11,6 @@ export { IWebController, WebviewControllerAdapter, FakeWebController } from './s // 桥 JS 读取器 export { BridgeJsLoader, BRIDGE_JS_RAWFILE } from './src/main/ets/assets/BridgeJsLoader'; + +// 通用网页容器注入对象(§11.3,第二套机制) +export { SettingsProxy, SettingsStrFn, SettingsFinishFn, SettingsBoolFn } from './src/main/ets/web/SettingsProxy'; diff --git a/feature_bridge/src/main/ets/web/SettingsProxy.ets b/feature_bridge/src/main/ets/web/SettingsProxy.ets new file mode 100644 index 0000000..9c5f130 --- /dev/null +++ b/feature_bridge/src/main/ets/web/SettingsProxy.ets @@ -0,0 +1,61 @@ +/** + * 通用网页容器注入对象 settings(契约 §11.3,框架 §7.2)。 + * + * 对象名固定 'settings',经 Web().javaScriptProxy 注入;H5 直接调 window.settings.<方法>(...)。 + * 6 个方法委托给容器传入的回调(ArkTS 严格模式下用函数参数而非对象字面量)。 + * ⚠ 这是与主容器(桥协议)不同的【第二套】机制,该容器无 WebViewJavascriptBridge。 + */ + +/** 回调类型:入参字符串。 */ +export type SettingsStrFn = (s: string) => void; +/** 回调类型:关闭(returnData 非空则 101 带数据回传)。 */ +export type SettingsFinishFn = (returnData: string | undefined) => void; +/** 回调类型:布尔开关。 */ +export type SettingsBoolFn = (on: boolean) => void; + +export class SettingsProxy { + private readonly loadUrlFn: SettingsStrFn; + private readonly browserFn: SettingsStrFn; + private readonly finishFn: SettingsFinishFn; + private readonly exitDialogFn: SettingsBoolFn; + private readonly backToH5Fn: SettingsBoolFn; + + constructor(loadUrlFn: SettingsStrFn, browserFn: SettingsStrFn, finishFn: SettingsFinishFn, + exitDialogFn: SettingsBoolFn, backToH5Fn: SettingsBoolFn) { + this.loadUrlFn = loadUrlFn; + this.browserFn = browserFn; + this.finishFn = finishFn; + this.exitDialogFn = exitDialogFn; + this.backToH5Fn = backToH5Fn; + } + + /** H5→原生:带数据返回上层(原生 setResult(101,{data}) 并关闭)。 */ + backgameData(data: string): void { + this.finishFn(data); + } + + /** H5→原生:本容器加载新 url。 */ + loadurl(urls: string): void { + this.loadUrlFn(urls); + } + + /** H5→原生:浏览器打开。 */ + browser(browserurl: string): void { + this.browserFn(browserurl); + } + + /** H5→原生:直接关闭本页。 */ + finishweb(): void { + this.finishFn(undefined); + } + + /** H5→原生:开启返回退出确认框。 */ + isexitdialogeshow(): void { + this.exitDialogFn(true); + } + + /** H5→原生:返回键交给 H5。 */ + isbackfinishweb(): void { + this.backToH5Fn(true); + } +} diff --git a/feature_capabilities/Index.ets b/feature_capabilities/Index.ets index 7f69034..e0a5157 100644 --- a/feature_capabilities/Index.ets +++ b/feature_capabilities/Index.ets @@ -22,6 +22,7 @@ export { ScanProvider } from './src/main/ets/providers/ScanProvider'; export { CameraProvider } from './src/main/ets/providers/CameraProvider'; export { LocationProvider } from './src/main/ets/providers/LocationProvider'; export { AudioProvider } from './src/main/ets/providers/AudioProvider'; +export { NavProvider } from './src/main/ets/providers/NavProvider'; // 微信 SDK 封装(EntryAbility 处理回调 want 需用) export { WeChatApi, WX_APP_ID } from './src/main/ets/wx/WeChatApi'; diff --git a/feature_capabilities/src/main/ets/providers/NavProvider.ets b/feature_capabilities/src/main/ets/providers/NavProvider.ets new file mode 100644 index 0000000..66f660c --- /dev/null +++ b/feature_capabilities/src/main/ets/providers/NavProvider.ets @@ -0,0 +1,77 @@ +/** + * 导航能力(契约 §8.8/§8.9/§8.10/§11,T-M3-06)。 + * - OpenurlTitleData:打开通用网页容器(路径 B,§11.2),关闭回传 → 上层 getWebdata。 + * - SwitchOverGameData:容器内切换子游戏(路径 A,§11.1),同容器 loadUrl。 + * - getGameinstall:同步返回子游戏资源是否就绪("1"/"0")。 + * - backgameData:子游戏带数据返回大厅。 + * + * 容器持 pathStack/WebviewController,本 Provider 经 EventBus 发导航事件,由 BridgeGameContainer 执行。 + */ +import { common } from '@kit.AbilityKit'; +import { FileSystem } from 'platform'; +import { ResourceManager, ConfigManager } from 'domain_resource'; +import { BridgeController } from 'feature_bridge'; +import { InboundHandlers, OpenUrlTitleDataReq, SwitchOverGameReq } from 'contracts'; +import { EventBus, NavEvents, OpenGenericWebPayload, SwitchGamePayload, BackGamePayload, Logger } from 'common'; +import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider'; + +export class NavProvider implements CapabilityProvider { + readonly name: string = 'nav'; + private readonly log: Logger = Logger.tag('NavProvider'); + private context: common.UIAbilityContext | undefined = undefined; + private config: ConfigManager | undefined = undefined; + + register(bridge: BridgeController, ctx: CapabilityContext): void { + this.context = ctx.uiAbilityContext; + this.config = ctx.config; + + bridge.registerHandler(InboundHandlers.OpenUrlTitleData, (data: string, _cb: (resp: string) => void) => { + this.openUrl(data); + }); + bridge.registerHandler(InboundHandlers.SwitchOverGameData, (data: string, _cb: (resp: string) => void) => { + this.switchGame(data); + }); + bridge.registerHandler(InboundHandlers.GetGameInstall, (data: string, cb: (resp: string) => void) => { + cb(this.isGameInstalled(data) ? '1' : '0'); + }); + bridge.registerHandler(InboundHandlers.BackGameData, (data: string, _cb: (resp: string) => void) => { + const payload: BackGamePayload = { data }; + EventBus.emit(NavEvents.BACK_GAME, payload); + }); + } + + private openUrl(data: string): void { + let req: OpenUrlTitleDataReq; + try { + req = JSON.parse(data) as OpenUrlTitleDataReq; + } catch (e) { + this.log.w(`OpenurlTitleData bad json: ${(e as Error).message}`); + return; + } + const payload: OpenGenericWebPayload = { url: req.url, title: req.title, data: req.data, orientation: '' }; + EventBus.emit(NavEvents.OPEN_GENERIC_WEB, payload); + } + + private switchGame(data: string): void { + let req: SwitchOverGameReq; + try { + req = JSON.parse(data) as SwitchOverGameReq; + } catch (e) { + this.log.w(`SwitchOverGameData bad json: ${(e as Error).message}`); + return; + } + const payload: SwitchGamePayload = { webtype: req.webtype, dir: req.Gamedirectory, data: req.data }; + EventBus.emit(NavEvents.SWITCH_GAME, payload); + } + + /** 子游戏资源是否就绪:<解压根>//index.html 存在。 */ + private isGameInstalled(dir: string): boolean { + const ctx = this.context; + const cfg = this.config; + if (ctx === undefined || cfg === undefined || dir === '') { + return false; + } + const urlpath: string = `${ResourceManager.resourceRoot(ctx)}/${cfg.getLocal().gamedir}`; + return FileSystem.exists(`${urlpath}/${dir}/index.html`); + } +}