diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index af8435b..87a637d 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -2,138 +2,104 @@ 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, AppDataInjector, AppDataValues } from 'domain_resource'; import { KvStore, LocalUploadServer } from 'platform'; import { OutboundHandlers } from 'contracts'; 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'; +import { WebSlot } from '../web/WebSlot'; /** - * 大厅/子游戏容器(对应 webviewActivity,框架 §7.1)。 - * 挂 Web + 接桥 + 经组装根注册全部能力 + 首次进度 100% 推 appservice/setPostUrl。 - * 子游戏切换(路径 A)/打开通用容器(路径 B)/返回大厅 经 NavEvents 由 NavProvider 触发、本容器执行。 + * 大厅/子游戏容器(对应 webviewActivity,框架 §7.1/§7.5)。**双 Web 槽模型**: + * - 大厅 Web 常驻(始终在 Stack);子游戏 Web 进入创建/返回销毁(subgameUrl 空/非空驱动)。 + * - 同时只有一个激活:进子游戏 → 大厅 deactivate(appservice'2'+停能力+桥关+onInactive+隐藏)、子游戏 activate; + * 返回大厅 → 销毁子游戏、大厅 resumeLoaded(appservice'1'+getWebdata)。 + * - 每槽独立桥+能力(结构隔离,接口不串);cookie 全局共享、localStorage file:// 同源共享(已验证)。 */ @Component export struct BridgeGameContainer { pathStack: NavPathStack = new NavPathStack(); params: BridgeGameParams = { entryUrl: '', launchType: '0' }; - private controller: webview.WebviewController = new webview.WebviewController(); - private adapter: WebviewControllerAdapter | undefined = undefined; - private bridge: BridgeController | undefined = undefined; - private registrar: CapabilityRegistrar | undefined = undefined; + private controllerL: webview.WebviewController = new webview.WebviewController(); + private controllerS: webview.WebviewController = new webview.WebviewController(); private uploadServer: LocalUploadServer = new LocalUploadServer(); private hostCtx: common.UIAbilityContext | undefined = undefined; private config: ConfigManager | undefined = undefined; + private kv: KvStore | undefined = undefined; private resource: ResourceManager | undefined = undefined; - private firstProgressDone: boolean = false; - private pendingWebdata: string | undefined = undefined; + private slotL: WebSlot | undefined = undefined; + private slotS: WebSlot | undefined = undefined; private cancels: Array<() => void> = []; + /** 非空 → 子游戏 Web 显示(路径 A 切换)。 */ + @State private subgameUrl: string = ''; aboutToAppear(): void { - // 远程调试仅 debug 包开启,release 自动关闭(CLAUDE.md 附录 B 红线) if (AppEnv.isDebug()) { webview.WebviewController.setWebDebuggingAccess(true); } - // 事件订阅移到 setupBridge(桥就位后),避免冷启动事件早于桥到达被丢 } aboutToDisappear(): void { - this.registrar?.destroyAll(); - this.bridge?.dispose(); - this.adapter?.dispose(); + this.slotS?.dispose(); + this.slotL?.dispose(); for (const cancel of this.cancels) { cancel(); } this.cancels = []; this.uploadServer.stop(); - this.registrar = undefined; - this.bridge = undefined; - this.adapter = undefined; + this.slotS = undefined; + this.slotL = undefined; this.hostCtx = undefined; this.config = undefined; + this.kv = undefined; this.resource = undefined; } - /** 页面进度首次 100% 时下发 appservice/setPostUrl(+子游戏返回大厅待发的 getWebdata)。幂等。 - * firstProgressDone 在子游戏切换/返回大厅时复位,使重载后再次下发。 */ - private firstReadyPush(): void { - if (this.firstProgressDone) { + /** 懒初始化共享依赖(两槽共用 config/kv/resource)+ 订阅事件。 */ + private ensureDeps(): void { + if (this.hostCtx !== undefined) { return; } - this.firstProgressDone = true; - const b = this.bridge; - if (b !== undefined) { - b.callHandler(OutboundHandlers.AppService, '1'); - b.callHandler(OutboundHandlers.SetPostUrl, this.uploadServer.baseUrl()); - if (this.pendingWebdata !== undefined) { - b.callHandler(OutboundHandlers.GetWebData, this.pendingWebdata); - this.pendingWebdata = undefined; - } - } - } - - /** onControllerAttached:建桥 + 经组装根注册全部能力(框架 §A.1)。 */ - private setupBridge(): void { const hostCtx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; this.hostCtx = hostCtx; - - // 🔴 V2 file:// 跨域(框架 §7.3):资源根加入"允许跨域访问"白名单(单一来源取自 ResourceManager) - try { - this.controller.setPathAllowingUniversalAccess([ResourceManager.resourceRoot(hostCtx)]); - } catch (e) { - Logger.tag('BridgeGameContainer').e(`setPathAllowingUniversalAccess failed: ${(e as Error).message}`); - } - - // 🔴 先建桥并就位:能力注册/配置加载抛错也不致桥失效(未注册 handler 落 DefaultHandler,铁律兜底) - const adapter = new WebviewControllerAdapter(this.controller); - const bridge = new BridgeController(adapter); - this.adapter = adapter; - this.bridge = bridge; - - 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, - kv, - uploadServer: this.uploadServer, - log: Logger.tag('Capability'), - }; - const registrar = new CapabilityRegistrar(buildCapabilities()); - registrar.registerAll(bridge, ctx); - this.registrar = registrar; - } catch (e) { - Logger.tag('BridgeGameContainer').e(`capability registration failed (bridge still usable): ${(e as Error).message}`); - } - - this.subscribeEvents(); + this.config = ConfigManager.load(hostCtx); + this.kv = KvStore.create(hostCtx); + this.resource = new ResourceManager(hostCtx, this.config.getLocal(), this.kv); this.uploadServer.start(); + this.subscribeEvents(); + } + + private resourceRoot(): string { + return ResourceManager.resourceRoot(this.hostCtx as common.UIAbilityContext); + } + + private setupLobby(): void { + this.ensureDeps(); + if (this.slotL === undefined) { + this.slotL = new WebSlot('lobby', this.controllerL, true, this.uploadServer); + } + this.slotL.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot()); + } + + private setupSubgame(): void { + this.ensureDeps(); + this.slotS?.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot()); + } + + private activeSlot(): WebSlot | undefined { + return this.subgameUrl !== '' ? this.slotS : this.slotL; } - /** 订阅前后台 + 导航事件(桥就位后)。 */ 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(AppEvents.FOREGROUND, () => this.activeSlot()?.onAppForeground())); + this.cancels.push(EventBus.on(AppEvents.BACKGROUND, () => this.activeSlot()?.onAppBackground())); 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')。 */ + /** 路径 B:打开通用网页容器;关闭回传 → 当前激活槽 callHandler('getWebdata')。 */ private openGenericWeb(p?: EventPayload): void { if (p === undefined) { return; @@ -142,41 +108,45 @@ export struct BridgeGameContainer { 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 : ''); + this.activeSlot()?.bridge()?.callHandler(OutboundHandlers.GetWebData, result !== undefined ? result.data : ''); }, false); } - /** 路径 A:同容器切换子游戏(loadUrl + 重注入 app_data + 方向 + 复位首屏标志)。 */ + /** 路径 A:大厅 → 子游戏(大厅去激活并保活,创建子游戏 Web)。 */ private switchGame(p?: EventPayload): void { - if (p === undefined) { + if (p === undefined || this.subgameUrl !== '') { 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) { + if (d.dir === '' || res === undefined || cfg === undefined) { return; } - const subDir: string = `${res.paths().urlpath}/${dir}`; - // 子游戏目录需自带 app_data.js(H5 同步读),launchtype='1' + const subDir: string = `${res.paths().urlpath}/${d.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`); + this.slotL?.deactivate(); + this.slotS = new WebSlot('subgame', this.controllerS, true, this.uploadServer); + this.applyOrientation(d.webtype !== '2'); // "3" 横 / "2" 竖 + this.subgameUrl = `file://${subDir}/index.html?Launchtype=1`; // 触发子游戏 Web 渲染 → setupSubgame } - /** 子游戏带数据返回大厅:重载大厅入口,加载完成后出站 getWebdata。 */ + /** 子游戏 → 大厅(销毁子游戏 Web,大厅恢复并带回数据)。 */ private backToLobby(p?: EventPayload): void { - if (this.params.entryUrl === '' || p === undefined) { + if (this.subgameUrl === '') { return; } - this.pendingWebdata = (p as BackGamePayload).data; - this.firstProgressDone = false; - this.applyOrientation(true); // 大厅横屏 - this.controller.loadUrl(this.params.entryUrl); + const data: string = p !== undefined ? (p as BackGamePayload).data : ''; + this.slotS?.dispose(); + this.slotS = undefined; + this.subgameUrl = ''; // 移除子游戏 Web + this.applyOrientation(true); // 大厅横屏 + if (this.slotL !== undefined) { + this.slotL.pendingWebdata = data; + this.slotL.resumeLoaded(); + } } private applyOrientation(landscape: boolean): void { @@ -189,42 +159,50 @@ export struct BridgeGameContainer { .catch((e: BusinessError) => Logger.tag('BridgeGameContainer').w(`orientation failed: ${e.message}`)); } - /** 入口 URL:params 非空用之,否则回退最小回显页(V1)。 */ - private webSrc(): string | Resource { + /** 大厅入口 URL:params 非空用之,否则回退最小回显页(V1)。 */ + private lobbySrc(): string | Resource { return this.params.entryUrl !== '' ? this.params.entryUrl : $rawfile('test_echo.html'); } build() { NavDestination() { - Web({ src: this.webSrc(), controller: this.controller }) - .javaScriptAccess(true) - .domStorageAccess(true) - .fileAccess(true) - .mixedMode(MixedMode.All) - .cacheMode(CacheMode.None) - .geolocationAccess(true) - .zoomAccess(false) - .width('100%') - .height('100%') - .onControllerAttached(() => { - this.setupBridge(); - }) - .onLoadIntercept((event: OnLoadInterceptEvent) => { - const url: string = event.data.getRequestUrl(); - const b = this.bridge; - return b !== undefined ? b.onLoadIntercept(url) : false; - }) - .onPageEnd(() => { - this.bridge?.onPageEnd(); - }) - .onProgressChange((event: OnProgressChangeEvent) => { - // 首次/每次切换后页面到 100% → 推 appservice(前台)+setPostUrl(对齐 Android onProgressChanged==100)。 - // 用 100%(而非更早的 onPageEnd):确保 H5 在 WebViewJavascriptBridgeReady 里已注册 appservice/setPostUrl - // 等 handler,避免出站早于注册被丢。loadUrl 切子游戏/返回大厅会再次产生 100% 回调(已复位 firstProgressDone)。 - if (event.newProgress === 100) { - this.firstReadyPush(); - } - }) + Stack() { + // 大厅 Web(常驻) + Web({ src: this.lobbySrc(), controller: this.controllerL }) + .javaScriptAccess(true).domStorageAccess(true).fileAccess(true) + .mixedMode(MixedMode.All).cacheMode(CacheMode.None) + .geolocationAccess(true).zoomAccess(false) + .width('100%').height('100%') + .visibility(this.subgameUrl !== '' ? Visibility.Hidden : Visibility.Visible) + .onControllerAttached(() => this.setupLobby()) + .onLoadIntercept((event: OnLoadInterceptEvent) => + this.slotL !== undefined ? this.slotL.onLoadIntercept(event.data.getRequestUrl()) : false) + .onPageEnd(() => this.slotL?.onPageEnd()) + .onProgressChange((event: OnProgressChangeEvent) => { + if (event.newProgress === 100) { + this.slotL?.onFirstProgress(); + } + }) + + // 子游戏 Web(临时,subgameUrl 非空时存在) + if (this.subgameUrl !== '') { + Web({ src: this.subgameUrl, controller: this.controllerS }) + .javaScriptAccess(true).domStorageAccess(true).fileAccess(true) + .mixedMode(MixedMode.All).cacheMode(CacheMode.None) + .geolocationAccess(true).zoomAccess(false) + .width('100%').height('100%') + .onControllerAttached(() => this.setupSubgame()) + .onLoadIntercept((event: OnLoadInterceptEvent) => + this.slotS !== undefined ? this.slotS.onLoadIntercept(event.data.getRequestUrl()) : false) + .onPageEnd(() => this.slotS?.onPageEnd()) + .onProgressChange((event: OnProgressChangeEvent) => { + if (event.newProgress === 100) { + this.slotS?.onFirstProgress(); + } + }) + } + } + .width('100%').height('100%') } .hideTitleBar(true) } diff --git a/entry/src/main/ets/web/WebSlot.ets b/entry/src/main/ets/web/WebSlot.ets new file mode 100644 index 0000000..fc28a67 --- /dev/null +++ b/entry/src/main/ets/web/WebSlot.ets @@ -0,0 +1,159 @@ +import { webview } from '@kit.ArkWeb'; +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge'; +import { CapabilityContext, CapabilityRegistrar } from 'feature_capabilities'; +import { ConfigManager } from 'domain_resource'; +import { KvStore, LocalUploadServer } from 'platform'; +import { OutboundHandlers } from 'contracts'; +import { Logger } from 'common'; +import { buildCapabilities } from '../di/AppModule'; + +/** + * 单个 Web 槽(M5 双 Web 架构,框架 §7.5)。封装一个 Web 的桥 + 能力 + 激活生命周期。 + * 大厅槽常驻;子游戏槽进入创建/返回销毁。每槽独立 BridgeController + 能力(结构隔离,接口不串)。 + * + * controller 由容器持有并绑定到对应 Web 组件;本类在 onControllerAttached 后 setup()。 + */ +export class WebSlot { + private static readonly log: Logger = Logger.tag('WebSlot'); + readonly role: string; + readonly controller: webview.WebviewController; + /** 子游戏返回大厅时待下发给大厅的数据(getWebdata)。 */ + pendingWebdata: string | undefined = undefined; + + private readonly uploadServer: LocalUploadServer; + private adapter: WebviewControllerAdapter | undefined = undefined; + private bridgeCtrl: BridgeController | undefined = undefined; + private registrar: CapabilityRegistrar | undefined = undefined; + private firstDone: boolean = false; + private active: boolean; + + constructor(role: string, controller: webview.WebviewController, active: boolean, uploadServer: LocalUploadServer) { + this.role = role; + this.controller = controller; + this.active = active; + this.uploadServer = uploadServer; + } + + bridge(): BridgeController | undefined { + return this.bridgeCtrl; + } + + isActive(): boolean { + return this.active; + } + + /** onControllerAttached:建桥 + 经组装根注册能力(每槽独立一套)。 */ + setup(hostCtx: common.UIAbilityContext, config: ConfigManager, kv: KvStore, resourceRoot: string): void { + try { + this.controller.setPathAllowingUniversalAccess([resourceRoot]); + } catch (e) { + WebSlot.log.e(`[${this.role}] setPathAllowingUniversalAccess failed: ${(e as Error).message}`); + } + const adapter = new WebviewControllerAdapter(this.controller); + const bridge = new BridgeController(adapter); + this.adapter = adapter; + this.bridgeCtrl = bridge; + bridge.setActive(this.active); + try { + bridge.setBridgeJs(BridgeJsLoader.load(hostCtx)); + const ctx: CapabilityContext = { + uiAbilityContext: hostCtx, + config, + kv, + uploadServer: this.uploadServer, + log: Logger.tag(`Cap-${this.role}`), + }; + const registrar = new CapabilityRegistrar(buildCapabilities()); + registrar.registerAll(bridge, ctx); + this.registrar = registrar; + } catch (e) { + WebSlot.log.e(`[${this.role}] capability registration failed (bridge usable): ${(e as Error).message}`); + } + } + + onLoadIntercept(url: string): boolean { + const b = this.bridgeCtrl; + return b !== undefined ? b.onLoadIntercept(url) : false; + } + + onPageEnd(): void { + this.bridgeCtrl?.onPageEnd(); + } + + /** 页面首次 100%:下发 appservice('1') + setPostUrl(+ 返回大厅待发 getWebdata)。 */ + onFirstProgress(): void { + if (this.firstDone) { + return; + } + this.firstDone = true; + const b = this.bridgeCtrl; + if (b === undefined) { + return; + } + b.callHandler(OutboundHandlers.AppService, '1'); + b.callHandler(OutboundHandlers.SetPostUrl, this.uploadServer.baseUrl()); + this.flushPending(); + } + + /** 激活(onActive + 桥放行 + 能力前台)。页面已加载的大厅返回时另调 resumeLoaded 补发 appservice。 */ + activate(): void { + this.active = true; + try { + this.controller.onActive(); + } catch (e) { + WebSlot.log.w(`[${this.role}] onActive failed: ${(e as BusinessError).message}`); + } + this.bridgeCtrl?.setActive(true); + this.registrar?.forEachForeground(); + } + + /** 大厅从子游戏返回(页面仍在、无新 100%):激活并直接补发 appservice('1') + 待发 getWebdata。 */ + resumeLoaded(): void { + this.activate(); + this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '1'); + this.flushPending(); + } + + /** 去激活(先发 appservice('2'),再停能力、关桥、onInactive)。 */ + deactivate(): void { + this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '2'); // 彼时仍激活,可下发 + this.registrar?.forEachBackground(); + this.bridgeCtrl?.setActive(false); + this.active = false; + try { + this.controller.onInactive(); + } catch (e) { + WebSlot.log.w(`[${this.role}] onInactive failed: ${(e as BusinessError).message}`); + } + } + + /** App 前后台 → 当前激活槽下发 appservice。 */ + onAppForeground(): void { + this.registrar?.forEachForeground(); + this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '1'); + } + + onAppBackground(): void { + this.registrar?.forEachBackground(); + this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '2'); + } + + /** 销毁(子游戏返回大厅时)。 */ + dispose(): void { + this.registrar?.destroyAll(); + this.bridgeCtrl?.dispose(); + this.adapter?.dispose(); + this.registrar = undefined; + this.bridgeCtrl = undefined; + this.adapter = undefined; + } + + private flushPending(): void { + if (this.pendingWebdata !== undefined) { + this.bridgeCtrl?.callHandler(OutboundHandlers.GetWebData, this.pendingWebdata); + this.pendingWebdata = undefined; + } + } +} diff --git a/entry/src/main/resources/rawfile/gamehall_builtin.zip b/entry/src/main/resources/rawfile/gamehall_builtin.zip index f7fb503..b0dee88 100644 Binary files a/entry/src/main/resources/rawfile/gamehall_builtin.zip and b/entry/src/main/resources/rawfile/gamehall_builtin.zip differ