M4: 通用网页容器 + SettingsProxy + NavProvider + 子游戏切换
T-M4-01/02/03 GenericWebContainer(§11.3 第二套机制,不挂桥):
- javaScriptProxy 注入 settings 对象 6 方法(SettingsProxy,函数回调避开 ArkTS 对象字面量限制)
- 首次 100% runJavaScript 直调 getWebdata('<data>');返回键 isbackfinishweb→gamebackkeydown /
isexitdialogeshow→退出确认→backgameData();关闭 pop({data}) 101 语义回上层
- orientation "1"竖/其他横(window.setPreferredOrientation);browser 隐式 want
T-M3-06 NavProvider:OpenurlTitleData(开通用容器,onPop→callHandler getWebdata)、
SwitchOverGameData(同容器 loadUrl 切子游戏+重注入 app_data(launchtype'1')+方向+复位首屏标志)、
getGameinstall(同步 1/0 查 <解压根>/<dir>/index.html)、backgameData(重载大厅+待加载后 getWebdata)
导航解耦:common NavEvents(名+具名 payload interface);NavProvider 发 EventBus → BridgeGameContainer 执行
(容器持 pathStack/controller)。EventBus payload 放宽为 Object(ArkTS 禁 Record 字面量),用 GenericEventData
domain AppDataInjector.buildValues 复用(StartupOrchestrator + 子游戏切换共用,去重)
组装根装配 NavProvider
devecocli build 通过。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
*/
|
||||
import { emitter } from '@kit.BasicServicesKit';
|
||||
|
||||
/** 事件载荷:键值对,值为 Object(需可跨线程时只放 Sendable/基本类型)。 */
|
||||
export type EventPayload = Record<string, Object>;
|
||||
/** 事件载荷:基类型 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<Object>) => {
|
||||
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<Object>) => {
|
||||
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<Object> = { data: payload };
|
||||
emitter.emit(eventId, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 的 <game name>,当前 VersionXml 仅解析 version。
|
||||
app_gamename: '',
|
||||
app_getwifisignalLevel: '0',
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成 app_data.js 文本(一组全局 var 声明)。 */
|
||||
static buildJs(v: AppDataValues): string {
|
||||
const lines: string[] = [
|
||||
|
||||
@@ -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 的 <game name>,当前 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) 进大厅
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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('<data>')。
|
||||
* - 返回键: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('<data>')(§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;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 子游戏资源是否就绪:<解压根>/<dir>/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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user