import { webview } from '@kit.ArkWeb'; import { common } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; import { BusinessError } from '@kit.BasicServicesKit'; 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 { WebSlot } from '../web/WebSlot'; /** * 大厅/子游戏容器(对应 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 controllerL: webview.WebviewController = new webview.WebviewController(); // 子游戏 controller 不复用:每次进子游戏新建(随 slotS),避免复用同一 controller 时 // onControllerAttached 不再触发导致第二个子游戏建不出桥(review 发现)。存于 slotS.controller。 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 slotL: WebSlot | undefined = undefined; private slotS: WebSlot | undefined = undefined; private cancels: Array<() => void> = []; /** 非空 → 子游戏 Web 显示(路径 A 切换)。 */ @State private subgameUrl: string = ''; aboutToAppear(): void { if (AppEnv.isDebug()) { webview.WebviewController.setWebDebuggingAccess(true); } } aboutToDisappear(): void { // 先退订事件,再 dispose——避免销毁期 SWITCH_GAME/FOREGROUND 等事件又触发建槽/回调 for (const cancel of this.cancels) { cancel(); } this.cancels = []; this.slotS?.dispose(); this.slotL?.dispose(); this.uploadServer.stop(); this.slotS = undefined; this.slotL = undefined; this.hostCtx = undefined; this.config = undefined; this.kv = undefined; this.resource = undefined; } /** 懒初始化共享依赖(两槽共用 config/kv/resource)+ 订阅事件。 */ private ensureDeps(): void { if (this.hostCtx !== undefined) { return; } const hostCtx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; this.hostCtx = hostCtx; 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.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')。 */ 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.activeSlot()?.bridge()?.callHandler(OutboundHandlers.GetWebData, result !== undefined ? result.data : ''); }, false); } /** 路径 A:大厅 → 子游戏(大厅去激活并保活,创建子游戏 Web)。 */ private switchGame(p?: EventPayload): void { if (p === undefined) { return; } // 拓扑约束:仅 大厅↔子游戏,无 子游戏→子游戏。已在子游戏中时忽略—— // 游戏逻辑须先 backgameData 回大厅、再由大厅发 SwitchOverGameData 进新子游戏。 if (this.subgameUrl !== '') { Logger.tag('BridgeGameContainer').w('SwitchOverGameData ignored: already in subgame (route via lobby)'); return; } const d = p as SwitchGamePayload; const res = this.resource; const cfg = this.config; if (d.dir === '' || res === undefined || cfg === undefined) { return; } const subDir: string = `${res.paths().urlpath}/${d.dir}`; // 子游戏目录自带 app_data.js(H5 同步读),launchtype='1' const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), `${res.localVersion()}`, '1', res.localGameName()); AppDataInjector.inject(subDir, values); this.slotL?.deactivate(); this.slotS = new WebSlot('subgame', new webview.WebviewController(), true, this.uploadServer); this.applyOrientation(d.webtype !== '2'); // "3" 横 / "2" 竖 this.subgameUrl = `file://${subDir}/index.html?Launchtype=1`; // 触发子游戏 Web 渲染 → setupSubgame } /** 子游戏 → 大厅(销毁子游戏 Web,大厅恢复并带回数据)。 */ private backToLobby(p?: EventPayload): void { if (this.subgameUrl === '') { return; } 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 { 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 lobbySrc(): string | Resource { return this.params.entryUrl !== '' ? this.params.entryUrl : $rawfile('test_echo.html'); } build() { NavDestination() { 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(); } }) .onRenderExited((event: OnRenderExitedEvent) => this.slotL?.onRenderExited(event.renderExitReason)) // 子游戏 Web(临时,subgameUrl 非空时存在) if (this.subgameUrl !== '' && this.slotS !== undefined) { Web({ src: this.subgameUrl, controller: this.slotS.controller }) .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(); } }) .onRenderExited((event: OnRenderExitedEvent) => this.slotS?.onRenderExited(event.renderExitReason)) } } .width('100%').height('100%') } .hideTitleBar(true) } }