M2(领域层): ConfigManager/VersionResolver/ResourceManager/AppDataInjector/StartupOrchestrator
T-M2-05 ConfigManager:本地 rawfile/app_config.json(§4.2 实测值)、远程 URL 拼装('-'→'/'+.txt+?a=ts 禁缓存)、
长度<30 走 showmessage 阻断、二级 url 二次请求(语义待真机校验)、产出版本决策
T-M2-06 VersionResolver:纯函数,agentlist/gamelist 两棵树 + agent→channel→market→game 后层覆盖 +
两树取更高 version(§4.4)。单测 8 用例全通过,覆盖率 81.8%(≥80%)
T-M2-07 ResourceManager:沙箱路径规范、内置包拷贝解压、version.xml 解析(@ohos.xml)、远程 zip 下载/删旧/解压、版本比较
T-M2-09 AppDataInjector:加载前写 <gameDir>/app_data.js(§6 全局变量同名同义,JSON.stringify 安全字面量)
T-M2-11 StartupOrchestrator:INIT→…→ENTER_HALL 状态机,远程失败降级本地、showmessage 阻断、weburl 空/非空入口规则
配套:module.json5 声明 ohos.permission.INTERNET + reason_internet 字符串
devecocli build 通过;scripts/test.sh 单测全通过(修复 PermissionRequestResult 顶层导入)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,19 @@
|
|||||||
// domain_resource HAR —— 领域服务层(导出入口 SSOT)
|
// domain_resource HAR —— 领域服务层(导出入口 SSOT,框架 §8)
|
||||||
// 各子模块实现完成后在此统一 export。占位常量确保 HAR 可编译。
|
|
||||||
export const domain_resource_MODULE_VERSION: string = '1.0.0';
|
// 配置
|
||||||
|
export { AppConfig, AppConfigRaw, normalizeConfig } from './src/main/ets/config/AppConfig';
|
||||||
|
export { ConfigManager, LOCAL_CONFIG_RAWFILE } from './src/main/ets/config/ConfigManager';
|
||||||
|
export { RemoteConfig, AgentNode, ChannelNode, MarketNode, GameOverrideNode, GameTreeNode,
|
||||||
|
VersionFields, MatchKeys, VersionDecision } from './src/main/ets/config/RemoteConfig';
|
||||||
|
|
||||||
|
// 版本决策(纯函数)
|
||||||
|
export { VersionResolver } from './src/main/ets/version/VersionResolver';
|
||||||
|
|
||||||
|
// 资源
|
||||||
|
export { VersionXml } from './src/main/ets/resource/VersionXml';
|
||||||
|
export { ResourceManager, ResourcePaths } from './src/main/ets/resource/ResourceManager';
|
||||||
|
export { AppDataInjector, AppDataValues } from './src/main/ets/resource/AppDataInjector';
|
||||||
|
|
||||||
|
// 启动编排
|
||||||
|
export { StartupOrchestrator, StartupStage, StartupResult, StageCallback }
|
||||||
|
from './src/main/ets/startup/StartupOrchestrator';
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 本地配置(契约 §4.2,框架 §8.2)。
|
||||||
|
*
|
||||||
|
* Android 用"目录名编码",本平台改为随包内置 rawfile/app_config.json(KV),
|
||||||
|
* 只要提供同名配置值即可,H5 完全无感知。本仓库实测值见 rawfile/app_config.json。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 本地配置(全键,§4.2)。 */
|
||||||
|
export interface AppConfig {
|
||||||
|
/** 代理商 ID(分层匹配 key)。 */
|
||||||
|
agent: string;
|
||||||
|
/** 渠道 ID(分层匹配 key)。 */
|
||||||
|
channel: string;
|
||||||
|
/** 资源解压父目录名。 */
|
||||||
|
gamedir: string;
|
||||||
|
/** 资源解压后的游戏目录名(大厅入口所在,通常 gamehall)。 */
|
||||||
|
gamestart: string;
|
||||||
|
/** App 版本号(与远程 app_version 比较)。 */
|
||||||
|
appversion: string;
|
||||||
|
/** 市场 ID(分层匹配 key;也经桥 getmarketname 暴露 H5)。 */
|
||||||
|
market: string;
|
||||||
|
/** 游戏 ID(空时回退用 version.xml game id)。 */
|
||||||
|
gameid: string;
|
||||||
|
/** 大厅 H5 远程地址;空 → 本地 file:// 加载。 */
|
||||||
|
weburl: string;
|
||||||
|
/** 远程配置地址('-'→'/' 后拼 .txt)。 */
|
||||||
|
gameconfig: string;
|
||||||
|
/** 业务自定义值(经桥 getOther/getothername 暴露 H5)。 */
|
||||||
|
other: string;
|
||||||
|
/** 推广/邀请码(写入 app_data.js 的 app_invitationcode)。 */
|
||||||
|
tuiguang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析中间体(字段全可选,用于从 JSON 归一化)。 */
|
||||||
|
export interface AppConfigRaw {
|
||||||
|
agent?: string;
|
||||||
|
channel?: string;
|
||||||
|
gamedir?: string;
|
||||||
|
gamestart?: string;
|
||||||
|
appversion?: string;
|
||||||
|
market?: string;
|
||||||
|
gameid?: string;
|
||||||
|
weburl?: string;
|
||||||
|
gameconfig?: string;
|
||||||
|
other?: string;
|
||||||
|
tuiguang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把可选解析体归一化为完整 AppConfig(缺失键 → 空串)。 */
|
||||||
|
export function normalizeConfig(raw: AppConfigRaw): AppConfig {
|
||||||
|
return {
|
||||||
|
agent: raw.agent ?? '',
|
||||||
|
channel: raw.channel ?? '',
|
||||||
|
gamedir: raw.gamedir ?? '',
|
||||||
|
gamestart: raw.gamestart ?? '',
|
||||||
|
appversion: raw.appversion ?? '',
|
||||||
|
market: raw.market ?? '',
|
||||||
|
gameid: raw.gameid ?? '',
|
||||||
|
weburl: raw.weburl ?? '',
|
||||||
|
gameconfig: raw.gameconfig ?? '',
|
||||||
|
other: raw.other ?? '',
|
||||||
|
tuiguang: raw.tuiguang ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* 配置管理(契约 §4,框架 §8.2)。负责:
|
||||||
|
* - 加载本地配置(rawfile/app_config.json)。
|
||||||
|
* - 拼远程配置 URL(gameconfig '-'→'/' + .txt),GET 禁缓存 + ?a=时间戳。
|
||||||
|
* - 校验(长度<30 视为错误文案 → 走 showmessage 阻断)、解析、二级 url 二次请求。
|
||||||
|
* - 经 VersionResolver 产出版本决策。
|
||||||
|
*/
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { util } from '@kit.ArkTS';
|
||||||
|
import { HttpClient } from 'platform';
|
||||||
|
import { Logger } from 'common';
|
||||||
|
import { AppConfig, AppConfigRaw, normalizeConfig } from './AppConfig';
|
||||||
|
import { AgentNode, GameTreeNode, MatchKeys, RemoteConfig, VersionDecision } from './RemoteConfig';
|
||||||
|
import { VersionResolver } from '../version/VersionResolver';
|
||||||
|
|
||||||
|
/** 本地配置 rawfile 文件名。 */
|
||||||
|
export const LOCAL_CONFIG_RAWFILE: string = 'app_config.json';
|
||||||
|
/** 远程配置错误文案阈值(<30 视为非 JSON 错误提示)。 */
|
||||||
|
const MIN_REMOTE_LEN: number = 30;
|
||||||
|
|
||||||
|
export class ConfigManager {
|
||||||
|
private static readonly log: Logger = Logger.tag('ConfigManager');
|
||||||
|
private readonly context: common.Context;
|
||||||
|
private readonly local: AppConfig;
|
||||||
|
|
||||||
|
private constructor(context: common.Context, local: AppConfig) {
|
||||||
|
this.context = context;
|
||||||
|
this.local = local;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 rawfile 加载本地配置。 */
|
||||||
|
static load(context: common.Context): ConfigManager {
|
||||||
|
const bytes: Uint8Array = context.resourceManager.getRawFileContentSync(LOCAL_CONFIG_RAWFILE);
|
||||||
|
const text: string = util.TextDecoder.create('utf-8').decodeToString(bytes);
|
||||||
|
const raw: AppConfigRaw = JSON.parse(text) as AppConfigRaw;
|
||||||
|
const local: AppConfig = normalizeConfig(raw);
|
||||||
|
ConfigManager.log.i(`local config loaded: agent=${local.agent} gamedir=${local.gamedir} weburl=${local.weburl}`);
|
||||||
|
return new ConfigManager(context, local);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocal(): AppConfig {
|
||||||
|
return this.local;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分层匹配 key(§4.4)。 */
|
||||||
|
matchKeys(): MatchKeys {
|
||||||
|
return {
|
||||||
|
agentid: this.local.agent,
|
||||||
|
channelid: this.local.channel,
|
||||||
|
marketid: this.local.market,
|
||||||
|
gameid: this.local.gameid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 远程配置主 URL(不含 ?a= 时间戳)。 */
|
||||||
|
remoteUrl(): string {
|
||||||
|
return `http://${this.local.gameconfig.replace(/-/g, '/')}.txt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取并解析远程配置。返回 RemoteConfig。
|
||||||
|
* 约定:长度<30 → 返回 { showmessage: 错误文案 }(统一走 showmessage 阻断)。
|
||||||
|
* 网络/解析失败抛出,由 StartupOrchestrator 降级到本地缓存。
|
||||||
|
*/
|
||||||
|
async fetchRemote(): Promise<RemoteConfig> {
|
||||||
|
const url: string = `${this.remoteUrl()}?a=${Date.now()}`;
|
||||||
|
const res = await HttpClient.getString(url);
|
||||||
|
if (res.code < 200 || res.code >= 300) {
|
||||||
|
throw new Error(`remote config http ${res.code}`);
|
||||||
|
}
|
||||||
|
const body: string = res.body.trim();
|
||||||
|
if (body.length < MIN_REMOTE_LEN) {
|
||||||
|
ConfigManager.log.w(`remote config too short (${body.length}) -> treat as block message`);
|
||||||
|
return { showmessage: body };
|
||||||
|
}
|
||||||
|
const remote: RemoteConfig = JSON.parse(body) as RemoteConfig;
|
||||||
|
await this.fetchSecondary(remote);
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取远程配置并产出版本决策(一步到位)。 */
|
||||||
|
async resolve(): Promise<VersionDecision> {
|
||||||
|
const remote: RemoteConfig = await this.fetchRemote();
|
||||||
|
return VersionResolver.resolve(remote, this.matchKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二级 url 二次请求(§4.3):匹配到的 agent/game 节点若带非空 url,
|
||||||
|
* 拉取其专属二级配置并用同 id 节点的子树覆盖内联节点。
|
||||||
|
* ⚠ 合并语义按契约推断,需在 T-M2-12 用真实服务端响应校验;失败静默回退内联配置。
|
||||||
|
*/
|
||||||
|
private async fetchSecondary(remote: RemoteConfig): Promise<void> {
|
||||||
|
const k: MatchKeys = this.matchKeys();
|
||||||
|
|
||||||
|
const agent: AgentNode | undefined =
|
||||||
|
(remote.agentlist ?? []).find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
|
||||||
|
if (agent !== undefined && agent.url !== undefined && agent.url !== '') {
|
||||||
|
const sub: RemoteConfig | undefined = await this.tryFetchSub(agent.url);
|
||||||
|
const subAgent: AgentNode | undefined =
|
||||||
|
sub === undefined ? undefined : (sub.agentlist ?? []).find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
|
||||||
|
if (subAgent !== undefined && subAgent.channellist !== undefined) {
|
||||||
|
agent.channellist = subAgent.channellist;
|
||||||
|
ConfigManager.log.i('secondary agent config merged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const game: GameTreeNode | undefined =
|
||||||
|
(remote.gamelist ?? []).find((g: GameTreeNode) => (g.gameid ?? '') === k.gameid);
|
||||||
|
if (game !== undefined && game.url !== undefined && game.url !== '') {
|
||||||
|
const sub: RemoteConfig | undefined = await this.tryFetchSub(game.url);
|
||||||
|
const subGame: GameTreeNode | undefined =
|
||||||
|
sub === undefined ? undefined : (sub.gamelist ?? []).find((g: GameTreeNode) => (g.gameid ?? '') === k.gameid);
|
||||||
|
if (subGame !== undefined && subGame.agentlist !== undefined) {
|
||||||
|
game.agentlist = subGame.agentlist;
|
||||||
|
ConfigManager.log.i('secondary game config merged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryFetchSub(url: string): Promise<RemoteConfig | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await HttpClient.getString(url);
|
||||||
|
if (res.code < 200 || res.code >= 300) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.parse(res.body.trim()) as RemoteConfig;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error;
|
||||||
|
ConfigManager.log.w(`secondary config fetch failed ${url}: ${err.message}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 远程配置 DTO(契约 §4.3 2.0 格式:agentlist / gamelist 两棵树并存)。
|
||||||
|
*
|
||||||
|
* 每层节点可携带可覆盖字段(§4.4):app_version/app_download/app_size/
|
||||||
|
* game_version/game_download/game_size/url/showmessage。越深层越后赋值,覆盖前层。
|
||||||
|
* 字段名与契约真实字段(非旧示意文档)严格一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 每层可覆盖的版本字段(§4.4)。空串视为"未设置",不覆盖前层。 */
|
||||||
|
export interface VersionFields {
|
||||||
|
app_version?: string;
|
||||||
|
app_download?: string;
|
||||||
|
app_size?: string;
|
||||||
|
game_version?: string;
|
||||||
|
game_download?: string;
|
||||||
|
game_size?: string;
|
||||||
|
/** 指向二级配置地址,非空则二次请求(§4.3)。 */
|
||||||
|
url?: string;
|
||||||
|
showmessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 市场层节点(marketid 命中;其 gamelist 为该市场下的游戏覆盖)。 */
|
||||||
|
export interface MarketNode extends VersionFields {
|
||||||
|
marketid?: string;
|
||||||
|
gamelist?: GameOverrideNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 市场下的游戏覆盖节点(gameid 命中,设 app/game 升级)。 */
|
||||||
|
export interface GameOverrideNode extends VersionFields {
|
||||||
|
gameid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渠道层节点。 */
|
||||||
|
export interface ChannelNode extends VersionFields {
|
||||||
|
channelid?: string;
|
||||||
|
marketlist?: MarketNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代理层节点。 */
|
||||||
|
export interface AgentNode extends VersionFields {
|
||||||
|
agentid?: string;
|
||||||
|
agentname?: string;
|
||||||
|
channellist?: ChannelNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 游戏树顶层节点(gamelist[]:gameid 命中;url 非空则二次请求;内含 agentlist)。 */
|
||||||
|
export interface GameTreeNode extends VersionFields {
|
||||||
|
gameid?: string;
|
||||||
|
agentlist?: AgentNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 远程配置根(2.0 格式)。 */
|
||||||
|
export interface RemoteConfig extends VersionFields {
|
||||||
|
/** 顶层公告:非空 → 阻断弹窗(§3.2)。 */
|
||||||
|
showmessage?: string;
|
||||||
|
/** 代理配置树。 */
|
||||||
|
agentlist?: AgentNode[];
|
||||||
|
/** 游戏配置树(2.0 才有;缺失即 1.0 老格式)。 */
|
||||||
|
gamelist?: GameTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分层匹配 key(来自本地配置 §4.2)。 */
|
||||||
|
export interface MatchKeys {
|
||||||
|
agentid: string;
|
||||||
|
channelid: string;
|
||||||
|
marketid: string;
|
||||||
|
gameid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最终版本决策结果。 */
|
||||||
|
export interface VersionDecision {
|
||||||
|
/** 顶层公告(非空则阻断)。 */
|
||||||
|
showmessage: string;
|
||||||
|
/** App 升级(取代理树/游戏树中 version 更高者)。 */
|
||||||
|
appVersion: number;
|
||||||
|
appDownload: string;
|
||||||
|
appSize: string;
|
||||||
|
/** 资源(游戏)升级(取更高者)。 */
|
||||||
|
gameVersion: number;
|
||||||
|
gameDownload: string;
|
||||||
|
gameSize: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* app_data.js 注入(契约 §6,框架 §8.4)。
|
||||||
|
*
|
||||||
|
* 🔴 时序铁律:必须在大厅 Web **加载页面之前**写入 <解压根>/gamehall/app_data.js
|
||||||
|
* (H5 首页用 <script src="app_data.js"> 同步读取)。由 StartupOrchestrator 的
|
||||||
|
* INJECT_APP_DATA 步调用,早于 ENTER_HALL。
|
||||||
|
*
|
||||||
|
* 全局变量名与含义严格照契约 §6(同名同义)。值用 JSON.stringify 生成安全 JS 字面量。
|
||||||
|
*/
|
||||||
|
import { FileSystem } from 'platform';
|
||||||
|
import { Logger } from 'common';
|
||||||
|
|
||||||
|
/** app_data.js 全局变量取值(§6)。 */
|
||||||
|
export interface AppDataValues {
|
||||||
|
/** version.xml 的 version(资源版本号)。 */
|
||||||
|
app_version: string;
|
||||||
|
/** gameconfig 配置(远程配置地址标识)。 */
|
||||||
|
app_gameconfig: string;
|
||||||
|
/** gamedir 配置(资源父目录名)。 */
|
||||||
|
app_gamedir: string;
|
||||||
|
/** gamestart 配置(游戏目录名 gamehall)。 */
|
||||||
|
app_gamestart: string;
|
||||||
|
/** agent 配置(代理商 ID)。 */
|
||||||
|
app_agent: string;
|
||||||
|
/** appversion 配置(App 版本号)。 */
|
||||||
|
app_appversion: string;
|
||||||
|
/** market 配置(市场 ID)。 */
|
||||||
|
app_market: string;
|
||||||
|
/** channel 配置(渠道 ID)。 */
|
||||||
|
app_channel: string;
|
||||||
|
/** tuiguang 配置(推广/邀请码)。 */
|
||||||
|
app_invitationcode: string;
|
||||||
|
/** 固定 '0'(大厅启动类型)。 */
|
||||||
|
app_Launchtype: string;
|
||||||
|
/** 游戏名。 */
|
||||||
|
app_gamename: string;
|
||||||
|
/** WiFi 信号等级初值。 */
|
||||||
|
app_getwifisignalLevel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppDataInjector {
|
||||||
|
private static readonly log: Logger = Logger.tag('AppDataInjector');
|
||||||
|
|
||||||
|
/** 生成 app_data.js 文本(一组全局 var 声明)。 */
|
||||||
|
static buildJs(v: AppDataValues): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
`var app_version=${JSON.stringify(v.app_version)};`,
|
||||||
|
`var app_gameconfig=${JSON.stringify(v.app_gameconfig)};`,
|
||||||
|
`var app_gamedir=${JSON.stringify(v.app_gamedir)};`,
|
||||||
|
`var app_gamestart=${JSON.stringify(v.app_gamestart)};`,
|
||||||
|
`var app_agent=${JSON.stringify(v.app_agent)};`,
|
||||||
|
`var app_appversion=${JSON.stringify(v.app_appversion)};`,
|
||||||
|
`var app_market=${JSON.stringify(v.app_market)};`,
|
||||||
|
`var app_channel=${JSON.stringify(v.app_channel)};`,
|
||||||
|
`var app_invitationcode=${JSON.stringify(v.app_invitationcode)};`,
|
||||||
|
`var app_Launchtype=${JSON.stringify(v.app_Launchtype)};`,
|
||||||
|
`var app_gamename=${JSON.stringify(v.app_gamename)};`,
|
||||||
|
`var app_getwifisignalLevel=${JSON.stringify(v.app_getwifisignalLevel)};`,
|
||||||
|
];
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 app_data.js 写入游戏目录(gameDir = <解压根>/gamehall)。 */
|
||||||
|
static inject(gameDir: string, v: AppDataValues): void {
|
||||||
|
const path: string = `${gameDir}/app_data.js`;
|
||||||
|
FileSystem.writeText(path, AppDataInjector.buildJs(v));
|
||||||
|
AppDataInjector.log.i(`app_data.js injected -> ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* 资源管理(契约 §5,框架 §8.3)。负责大厅/子游戏资源的落盘与版本管理:
|
||||||
|
* - 路径规范(沙箱 filesDir)。
|
||||||
|
* - 首启:拷贝内置预置包(rawfile zip) → 解压。
|
||||||
|
* - 版本:解析本地 version.xml,与远程 game_version 比较。
|
||||||
|
* - 更新:下载远程 zip → 删旧 gamehall → 解压 → 收尾。
|
||||||
|
* - 持久化 urlpath/upurlpath 到 KvStore(供容器读取)。
|
||||||
|
*
|
||||||
|
* 沙箱简化(§5.1 许可):弃用 Android 的"<包名>/<时间戳>"多级目录,用固定目录,
|
||||||
|
* 对 H5 无影响(H5 只看到自己被 file:// 加载)。
|
||||||
|
*/
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { Downloader, FileSystem, KvStore, ProgressCallback, Unzipper } from 'platform';
|
||||||
|
import { Logger } from 'common';
|
||||||
|
import { AppConfig } from '../config/AppConfig';
|
||||||
|
import { VersionXml } from './VersionXml';
|
||||||
|
|
||||||
|
/** 资源路径集合。 */
|
||||||
|
export interface ResourcePaths {
|
||||||
|
/** 父目录(upurlpath)。 */
|
||||||
|
upurlpath: string;
|
||||||
|
/** 解压根(urlpath)。 */
|
||||||
|
urlpath: string;
|
||||||
|
/** 游戏目录(<urlpath>/<gamestart>,即 gamehall)。 */
|
||||||
|
gameDir: string;
|
||||||
|
/** 大厅入口文件磁盘路径。 */
|
||||||
|
indexPath: string;
|
||||||
|
/** 大厅入口 file:// URL(不含 ?Launchtype)。 */
|
||||||
|
indexUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KvStore 键。 */
|
||||||
|
const KEY_URLPATH: string = 'urlpath';
|
||||||
|
const KEY_UPURLPATH: string = 'upurlpath';
|
||||||
|
/** 内置预置包 rawfile 名(可选;缺失则依赖远程下载)。 */
|
||||||
|
const BUILTIN_ZIP_RAWFILE: string = 'gamehall_builtin.zip';
|
||||||
|
|
||||||
|
export class ResourceManager {
|
||||||
|
private static readonly log: Logger = Logger.tag('ResourceManager');
|
||||||
|
private readonly context: common.Context;
|
||||||
|
private readonly config: AppConfig;
|
||||||
|
private readonly kv: KvStore;
|
||||||
|
private readonly cachedPaths: ResourcePaths;
|
||||||
|
|
||||||
|
constructor(context: common.Context, config: AppConfig, kv: KvStore) {
|
||||||
|
this.context = context;
|
||||||
|
this.config = config;
|
||||||
|
this.kv = kv;
|
||||||
|
this.cachedPaths = this.computePaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
private computePaths(): ResourcePaths {
|
||||||
|
const upurlpath: string = `${this.context.filesDir}/tsgames`;
|
||||||
|
const urlpath: string = `${upurlpath}/${this.config.gamedir}`;
|
||||||
|
const gameDir: string = `${urlpath}/${this.config.gamestart}`;
|
||||||
|
const indexPath: string = `${gameDir}/index.html`;
|
||||||
|
return { upurlpath, urlpath, gameDir, indexPath, indexUrl: `file://${indexPath}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
paths(): ResourcePaths {
|
||||||
|
return this.cachedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 大厅入口是否已就绪(index.html 存在)。 */
|
||||||
|
isPrepared(): boolean {
|
||||||
|
return FileSystem.exists(this.cachedPaths.indexPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地资源版本(version.xml 的 version;缺失返回 0)。 */
|
||||||
|
localVersion(): number {
|
||||||
|
const candidates: string[] = [
|
||||||
|
`${this.cachedPaths.gameDir}/version.xml`,
|
||||||
|
`${this.cachedPaths.urlpath}/version.xml`,
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (FileSystem.exists(p)) {
|
||||||
|
return VersionXml.parseVersion(FileSystem.readText(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 持久化路径到 KvStore(供容器/重启读取)。 */
|
||||||
|
async persistPaths(): Promise<void> {
|
||||||
|
this.kv.putString(KEY_URLPATH, this.cachedPaths.urlpath);
|
||||||
|
this.kv.putString(KEY_UPURLPATH, this.cachedPaths.upurlpath);
|
||||||
|
await this.kv.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首启拷贝内置预置包并解压(若 rawfile 存在)。
|
||||||
|
* 内置包顶层即含 gamehall/...,解压到 urlpath 即可。无内置包则跳过(依赖远程下载)。
|
||||||
|
*/
|
||||||
|
async prepareBuiltin(): Promise<void> {
|
||||||
|
let bytesAvailable: boolean = true;
|
||||||
|
try {
|
||||||
|
this.context.resourceManager.getRawFileContentSync(BUILTIN_ZIP_RAWFILE);
|
||||||
|
} catch (e) {
|
||||||
|
bytesAvailable = false;
|
||||||
|
}
|
||||||
|
if (!bytesAvailable) {
|
||||||
|
ResourceManager.log.w(`no builtin package (${BUILTIN_ZIP_RAWFILE}); rely on remote download`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tmpZip: string = `${this.cachedPaths.upurlpath}/builtin.zip`;
|
||||||
|
FileSystem.copyRawFileTo(this.context.resourceManager, BUILTIN_ZIP_RAWFILE, tmpZip);
|
||||||
|
FileSystem.ensureDir(this.cachedPaths.urlpath);
|
||||||
|
await Unzipper.unzip(tmpZip, this.cachedPaths.urlpath);
|
||||||
|
FileSystem.rmrf(tmpZip);
|
||||||
|
ResourceManager.log.i('builtin package prepared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从远程下载 zip 并更新游戏资源(契约 §5.4):
|
||||||
|
* 下载 game_download(?a=ts) → 删旧 gamehall → 解压到 urlpath → 删 zip。
|
||||||
|
*/
|
||||||
|
async updateGame(downloadUrl: string, onProgress?: ProgressCallback): Promise<void> {
|
||||||
|
const url: string = downloadUrl.includes('?') ? downloadUrl : `${downloadUrl}?a=${Date.now()}`;
|
||||||
|
const zipPath: string = `${this.cachedPaths.upurlpath}/dowlod_zip/${Date.now()}Projects.zip`;
|
||||||
|
await Downloader.download(url, zipPath, onProgress);
|
||||||
|
FileSystem.rmrf(this.cachedPaths.gameDir);
|
||||||
|
FileSystem.ensureDir(this.cachedPaths.urlpath);
|
||||||
|
await Unzipper.unzip(zipPath, this.cachedPaths.urlpath);
|
||||||
|
FileSystem.rmrf(zipPath);
|
||||||
|
ResourceManager.log.i(`game updated from ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* version.xml 解析(契约 §5.3,框架 §8.3)。用 @kit.ArkTS xml.XmlPullParser。
|
||||||
|
*
|
||||||
|
* 结构:<game><agent id name/><game id name/><channel id name/><version value name/></game>
|
||||||
|
* 主用途:取 <version value="42"> 与远程 game_version 比较决定是否更新。
|
||||||
|
* `value` 属性为 version 标签独有,故用 attributeValueCallbackFunction 即可稳定抓取。
|
||||||
|
*/
|
||||||
|
import { xml, util } from '@kit.ArkTS';
|
||||||
|
import { Logger } from 'common';
|
||||||
|
|
||||||
|
export class VersionXml {
|
||||||
|
private static readonly log: Logger = Logger.tag('VersionXml');
|
||||||
|
|
||||||
|
/** 解析 version 整数(解析失败/缺失 → 0)。 */
|
||||||
|
static parseVersion(xmlText: string): number {
|
||||||
|
if (xmlText.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let value: string = '';
|
||||||
|
try {
|
||||||
|
const buf: Uint8Array = new util.TextEncoder().encodeInto(xmlText);
|
||||||
|
const parser: xml.XmlPullParser = new xml.XmlPullParser(buf.buffer as object as ArrayBuffer, 'UTF-8');
|
||||||
|
const options: xml.ParseOptions = {
|
||||||
|
ignoreNameSpace: true,
|
||||||
|
attributeValueCallbackFunction: (name: string, val: string): boolean => {
|
||||||
|
if (name === 'value') {
|
||||||
|
value = val;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
parser.parseXml(options);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error;
|
||||||
|
VersionXml.log.w(`parse version.xml failed: ${err.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const n: number = parseInt(value, 10);
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 启动编排状态机(契约 §3,框架 §8.1)。把启动时序实现为显式状态机:
|
||||||
|
* INIT → LOAD_LOCAL_CONFIG → REQUEST_PERMISSIONS → FETCH_REMOTE_CONFIG
|
||||||
|
* → RESOLVE_VERSION → PREPARE_RESOURCE → INJECT_APP_DATA → ENTER_HALL
|
||||||
|
* 任一步失败 → FALLBACK(本地缓存) 或 BLOCK(showmessage 公告)。
|
||||||
|
*
|
||||||
|
* 权限:HarmonyOS 沙箱文件免存储权限;定位/电话等敏感权限由 M3 Provider 用时申请,
|
||||||
|
* 故启动期不弹窗(REQUEST_PERMISSIONS 当前为 no-op)。
|
||||||
|
*/
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { KvStore } from 'platform';
|
||||||
|
import { Logger } from 'common';
|
||||||
|
import { ConfigManager } from '../config/ConfigManager';
|
||||||
|
import { AppConfig } from '../config/AppConfig';
|
||||||
|
import { VersionDecision } from '../config/RemoteConfig';
|
||||||
|
import { ResourceManager } from '../resource/ResourceManager';
|
||||||
|
import { AppDataInjector, AppDataValues } from '../resource/AppDataInjector';
|
||||||
|
|
||||||
|
/** 启动阶段(用于进度展示/可观测)。 */
|
||||||
|
export enum StartupStage {
|
||||||
|
INIT = 'INIT',
|
||||||
|
LOAD_LOCAL_CONFIG = 'LOAD_LOCAL_CONFIG',
|
||||||
|
REQUEST_PERMISSIONS = 'REQUEST_PERMISSIONS',
|
||||||
|
FETCH_REMOTE_CONFIG = 'FETCH_REMOTE_CONFIG',
|
||||||
|
RESOLVE_VERSION = 'RESOLVE_VERSION',
|
||||||
|
PREPARE_RESOURCE = 'PREPARE_RESOURCE',
|
||||||
|
INJECT_APP_DATA = 'INJECT_APP_DATA',
|
||||||
|
ENTER_HALL = 'ENTER_HALL',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动结果。 */
|
||||||
|
export interface StartupResult {
|
||||||
|
/** true:进大厅;false:被公告阻断。 */
|
||||||
|
enter: boolean;
|
||||||
|
/** 大厅入口 URL(含 ?Launchtype=0),enter=true 时有效。 */
|
||||||
|
entryUrl: string;
|
||||||
|
/** 阻断公告文案,enter=false 时有效。 */
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阶段进度回调。 */
|
||||||
|
export type StageCallback = (stage: StartupStage, percent: number) => void;
|
||||||
|
|
||||||
|
export class StartupOrchestrator {
|
||||||
|
private static readonly log: Logger = Logger.tag('StartupOrchestrator');
|
||||||
|
private readonly context: common.UIAbilityContext;
|
||||||
|
private readonly onStage?: StageCallback;
|
||||||
|
|
||||||
|
constructor(context: common.UIAbilityContext, onStage?: StageCallback) {
|
||||||
|
this.context = context;
|
||||||
|
this.onStage = onStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stage(s: StartupStage, percent: number): void {
|
||||||
|
StartupOrchestrator.log.i(`stage=${s} ${percent}%`);
|
||||||
|
if (this.onStage !== undefined) {
|
||||||
|
this.onStage(s, percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行启动流程。 */
|
||||||
|
async run(): Promise<StartupResult> {
|
||||||
|
this.stage(StartupStage.INIT, 0);
|
||||||
|
|
||||||
|
// 1) 本地配置
|
||||||
|
this.stage(StartupStage.LOAD_LOCAL_CONFIG, 5);
|
||||||
|
const config: ConfigManager = ConfigManager.load(this.context);
|
||||||
|
const local: AppConfig = config.getLocal();
|
||||||
|
|
||||||
|
// 2) 权限(M2 no-op,敏感权限由 M3 Provider 用时申请)
|
||||||
|
this.stage(StartupStage.REQUEST_PERMISSIONS, 10);
|
||||||
|
|
||||||
|
const kv: KvStore = KvStore.create(this.context);
|
||||||
|
const resource: ResourceManager = new ResourceManager(this.context, local, kv);
|
||||||
|
|
||||||
|
// weburl 非空 → 远程 http 大厅,无需本地资源
|
||||||
|
if (local.weburl !== '') {
|
||||||
|
const entryUrl: string = `http://${local.weburl.replace(/-/g, '/')}?Launchtype=0`;
|
||||||
|
this.stage(StartupStage.ENTER_HALL, 100);
|
||||||
|
return { enter: true, entryUrl, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 远程配置 + 4) 版本决策
|
||||||
|
let decision: VersionDecision | undefined = undefined;
|
||||||
|
try {
|
||||||
|
this.stage(StartupStage.FETCH_REMOTE_CONFIG, 20);
|
||||||
|
decision = await config.resolve();
|
||||||
|
this.stage(StartupStage.RESOLVE_VERSION, 35);
|
||||||
|
if (decision.showmessage !== '') {
|
||||||
|
StartupOrchestrator.log.w(`blocked by showmessage: ${decision.showmessage}`);
|
||||||
|
return { enter: false, entryUrl: '', message: decision.showmessage };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error;
|
||||||
|
StartupOrchestrator.log.w(`remote config failed, fallback to local: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 资源准备
|
||||||
|
this.stage(StartupStage.PREPARE_RESOURCE, 45);
|
||||||
|
if (!resource.isPrepared()) {
|
||||||
|
await resource.prepareBuiltin();
|
||||||
|
}
|
||||||
|
const localVer: number = resource.localVersion();
|
||||||
|
if (decision !== undefined && decision.gameVersion > localVer && decision.gameDownload !== '') {
|
||||||
|
StartupOrchestrator.log.i(`update needed: local=${localVer} remote=${decision.gameVersion}`);
|
||||||
|
try {
|
||||||
|
await resource.updateGame(decision.gameDownload, (p: number) => this.stage(StartupStage.PREPARE_RESOURCE, 45 + Math.floor(p * 0.4)));
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error;
|
||||||
|
StartupOrchestrator.log.w(`update failed, keep local: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await resource.persistPaths();
|
||||||
|
|
||||||
|
if (!resource.isPrepared()) {
|
||||||
|
// 无可用资源且无网络 → 阻断
|
||||||
|
return { enter: false, entryUrl: '', message: '资源准备失败,请检查网络后重试' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 注入 app_data.js(务必在进大厅之前)
|
||||||
|
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',
|
||||||
|
app_gamename: '',
|
||||||
|
app_getwifisignalLevel: '0',
|
||||||
|
};
|
||||||
|
AppDataInjector.inject(resource.paths().gameDir, values);
|
||||||
|
|
||||||
|
// 7) 进大厅
|
||||||
|
this.stage(StartupStage.ENTER_HALL, 100);
|
||||||
|
return { enter: true, entryUrl: `${resource.paths().indexUrl}?Launchtype=0`, message: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 分层版本决策(契约 §4.4,框架 §8.2)。**纯函数,无 IO,便于单测**。
|
||||||
|
*
|
||||||
|
* 输入已拉取/合并好的远程配置(含两棵树)+ 本地匹配 key,输出最终 App/资源版本决策。
|
||||||
|
* 规则:
|
||||||
|
* - 代理树:agentid → channelid → marketid → (market.gamelist) gameid,**越深越后、覆盖前层**。
|
||||||
|
* - 游戏树:gameid → agentid → channelid → marketid,同样后层覆盖。
|
||||||
|
* - 最终:app 升级与 game 升级各自取"两棵树中 version 更高者"的下载地址。
|
||||||
|
* - 二级 `url` 二次请求(§4.3)属 IO,由 ConfigManager 编排后把合并结果喂给本函数。
|
||||||
|
*/
|
||||||
|
import { AgentNode, ChannelNode, GameOverrideNode, GameTreeNode, MarketNode,
|
||||||
|
MatchKeys, RemoteConfig, VersionDecision, VersionFields } from '../config/RemoteConfig';
|
||||||
|
|
||||||
|
/** 用 next 的非空字段覆盖 cur(空串/undefined 不覆盖)。 */
|
||||||
|
function ov(cur: string | undefined, next: string | undefined): string | undefined {
|
||||||
|
return (next !== undefined && next !== '') ? next : cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 n 的非空可覆盖字段并入 acc(深层覆盖浅层)。 */
|
||||||
|
function merge(acc: VersionFields, n: VersionFields): void {
|
||||||
|
acc.app_version = ov(acc.app_version, n.app_version);
|
||||||
|
acc.app_download = ov(acc.app_download, n.app_download);
|
||||||
|
acc.app_size = ov(acc.app_size, n.app_size);
|
||||||
|
acc.game_version = ov(acc.game_version, n.game_version);
|
||||||
|
acc.game_download = ov(acc.game_download, n.game_download);
|
||||||
|
acc.game_size = ov(acc.game_size, n.game_size);
|
||||||
|
acc.showmessage = ov(acc.showmessage, n.showmessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本号转整数(空/非数字 → 0)。 */
|
||||||
|
function parseVer(s: string | undefined): number {
|
||||||
|
if (s === undefined || s === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const n: number = parseInt(s, 10);
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取首个非空串。 */
|
||||||
|
function firstNonEmpty(a: string | undefined, b: string | undefined, c: string | undefined): string {
|
||||||
|
if (a !== undefined && a !== '') {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (b !== undefined && b !== '') {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
return (c !== undefined && c !== '') ? c : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VersionResolver {
|
||||||
|
/** 代理树:agentid → channelid → marketid → gameid,逐层并入。 */
|
||||||
|
static resolveAgentTree(agents: AgentNode[], k: MatchKeys): VersionFields {
|
||||||
|
const acc: VersionFields = {};
|
||||||
|
const agent: AgentNode | undefined = agents.find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
|
||||||
|
if (agent === undefined) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
merge(acc, agent);
|
||||||
|
const channel: ChannelNode | undefined =
|
||||||
|
(agent.channellist ?? []).find((c: ChannelNode) => (c.channelid ?? '') === k.channelid);
|
||||||
|
if (channel === undefined) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
merge(acc, channel);
|
||||||
|
const market: MarketNode | undefined =
|
||||||
|
(channel.marketlist ?? []).find((m: MarketNode) => (m.marketid ?? '') === k.marketid);
|
||||||
|
if (market === undefined) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
merge(acc, market);
|
||||||
|
const game: GameOverrideNode | undefined =
|
||||||
|
(market.gamelist ?? []).find((g: GameOverrideNode) => (g.gameid ?? '') === k.gameid);
|
||||||
|
if (game !== undefined) {
|
||||||
|
merge(acc, game);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 游戏树:gameid → agentid → channelid → marketid,逐层并入。 */
|
||||||
|
static resolveGameTree(games: GameTreeNode[], k: MatchKeys): VersionFields {
|
||||||
|
const acc: VersionFields = {};
|
||||||
|
const game: GameTreeNode | undefined = games.find((g: GameTreeNode) => (g.gameid ?? '') === k.gameid);
|
||||||
|
if (game === undefined) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
merge(acc, game);
|
||||||
|
const agent: AgentNode | undefined =
|
||||||
|
(game.agentlist ?? []).find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
|
||||||
|
if (agent === undefined) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
merge(acc, agent);
|
||||||
|
const channel: ChannelNode | undefined =
|
||||||
|
(agent.channellist ?? []).find((c: ChannelNode) => (c.channelid ?? '') === k.channelid);
|
||||||
|
if (channel === undefined) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
merge(acc, channel);
|
||||||
|
const market: MarketNode | undefined =
|
||||||
|
(channel.marketlist ?? []).find((m: MarketNode) => (m.marketid ?? '') === k.marketid);
|
||||||
|
if (market !== undefined) {
|
||||||
|
merge(acc, market);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最终决策:两棵树各取更高 version 的下载地址。 */
|
||||||
|
static resolve(remote: RemoteConfig, keys: MatchKeys): VersionDecision {
|
||||||
|
const a: VersionFields = VersionResolver.resolveAgentTree(remote.agentlist ?? [], keys);
|
||||||
|
const g: VersionFields = VersionResolver.resolveGameTree(remote.gamelist ?? [], keys);
|
||||||
|
|
||||||
|
const aGameV: number = parseVer(a.game_version);
|
||||||
|
const gGameV: number = parseVer(g.game_version);
|
||||||
|
const gameWin: VersionFields = gGameV > aGameV ? g : a;
|
||||||
|
|
||||||
|
const aAppV: number = parseVer(a.app_version);
|
||||||
|
const gAppV: number = parseVer(g.app_version);
|
||||||
|
const appWin: VersionFields = gAppV > aAppV ? g : a;
|
||||||
|
|
||||||
|
return {
|
||||||
|
showmessage: firstNonEmpty(a.showmessage, g.showmessage, remote.showmessage),
|
||||||
|
appVersion: Math.max(aAppV, gAppV),
|
||||||
|
appDownload: appWin.app_download ?? '',
|
||||||
|
appSize: appWin.app_size ?? '',
|
||||||
|
gameVersion: Math.max(aGameV, gGameV),
|
||||||
|
gameDownload: gameWin.game_download ?? '',
|
||||||
|
gameSize: gameWin.game_size ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,16 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"requestPermissions": [
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.INTERNET",
|
||||||
|
"reason": "$string:reason_internet",
|
||||||
|
"usedScene": {
|
||||||
|
"abilities": ["EntryAbility"],
|
||||||
|
"when": "inuse"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
{
|
{
|
||||||
"name": "EntryAbility_label",
|
"name": "EntryAbility_label",
|
||||||
"value": "label"
|
"value": "label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reason_internet",
|
||||||
|
"value": "用于下载游戏资源与获取远程配置"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"agent": "veRa0qrBf0df2K1G4de2tgfmVxB2jxpv",
|
||||||
|
"channel": "FtJf073aa0d6rI1xD8J1Y42fINTm0ziK",
|
||||||
|
"gamedir": "FtJf073aa0d6rI1xD8J1Y42fINTm0ziK",
|
||||||
|
"gamestart": "gamehall",
|
||||||
|
"appversion": "49",
|
||||||
|
"market": "3",
|
||||||
|
"gameid": "",
|
||||||
|
"weburl": "",
|
||||||
|
"gameconfig": "tsgames.daoqi88.cn-config_test-update_jsonv2_test",
|
||||||
|
"other": "",
|
||||||
|
"tuiguang": ""
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import localUnitTest from './LocalUnit.test';
|
import localUnitTest from './LocalUnit.test';
|
||||||
import bridgeTest from './Bridge.test';
|
import bridgeTest from './Bridge.test';
|
||||||
|
import versionResolverTest from './VersionResolver.test';
|
||||||
|
|
||||||
export default function testsuite() {
|
export default function testsuite() {
|
||||||
localUnitTest();
|
localUnitTest();
|
||||||
bridgeTest();
|
bridgeTest();
|
||||||
|
versionResolverTest();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from '@ohos/hypium';
|
||||||
|
import { VersionResolver, RemoteConfig, MatchKeys, VersionDecision } from 'domain_resource';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分层版本决策单测(T-M2-06,契约 §4.4)。纯函数,构造典型多层样例验证后层覆盖、
|
||||||
|
* 两棵树取更高 version、showmessage 阻断。
|
||||||
|
*/
|
||||||
|
export default function versionResolverTest() {
|
||||||
|
const keys: MatchKeys = { agentid: 'A1', channelid: 'C1', marketid: 'M1', gameid: 'G1' };
|
||||||
|
|
||||||
|
describe('VersionResolver', () => {
|
||||||
|
it('empty_config_zero_decision', 0, () => {
|
||||||
|
const d: VersionDecision = VersionResolver.resolve({}, keys);
|
||||||
|
expect(d.gameVersion).assertEqual(0);
|
||||||
|
expect(d.appVersion).assertEqual(0);
|
||||||
|
expect(d.showmessage).assertEqual('');
|
||||||
|
expect(d.gameDownload).assertEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent_tree_deeper_overrides', 0, () => {
|
||||||
|
// 代理树:agent 设 game_version=10,market 覆盖为 20(深层覆盖浅层)
|
||||||
|
const remote: RemoteConfig = {
|
||||||
|
agentlist: [{
|
||||||
|
agentid: 'A1', game_version: '10', game_download: 'a_url', game_size: '100',
|
||||||
|
channellist: [{
|
||||||
|
channelid: 'C1',
|
||||||
|
marketlist: [{ marketid: 'M1', game_version: '20', game_download: 'm_url' }],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const d: VersionDecision = VersionResolver.resolve(remote, keys);
|
||||||
|
expect(d.gameVersion).assertEqual(20);
|
||||||
|
expect(d.gameDownload).assertEqual('m_url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('market_game_level_overrides', 0, () => {
|
||||||
|
// market.gamelist 中 gameid 命中,覆盖到最深
|
||||||
|
const remote: RemoteConfig = {
|
||||||
|
agentlist: [{
|
||||||
|
agentid: 'A1',
|
||||||
|
channellist: [{
|
||||||
|
channelid: 'C1',
|
||||||
|
marketlist: [{
|
||||||
|
marketid: 'M1', game_version: '20',
|
||||||
|
gamelist: [{ gameid: 'G1', game_version: '30', game_download: 'g_url' }],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const d: VersionDecision = VersionResolver.resolve(remote, keys);
|
||||||
|
expect(d.gameVersion).assertEqual(30);
|
||||||
|
expect(d.gameDownload).assertEqual('g_url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two_trees_take_higher_version', 0, () => {
|
||||||
|
// 代理树 game_version=20,游戏树 game_version=25 → 取 25 及其下载地址
|
||||||
|
const remote: RemoteConfig = {
|
||||||
|
agentlist: [{ agentid: 'A1', game_version: '20', game_download: 'agent_url' }],
|
||||||
|
gamelist: [{
|
||||||
|
gameid: 'G1',
|
||||||
|
agentlist: [{ agentid: 'A1', game_version: '25', game_download: 'game_url' }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const d: VersionDecision = VersionResolver.resolve(remote, keys);
|
||||||
|
expect(d.gameVersion).assertEqual(25);
|
||||||
|
expect(d.gameDownload).assertEqual('game_url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('app_and_game_independent_winners', 0, () => {
|
||||||
|
// app 升级取代理树(app_version=49),game 升级取游戏树(game_version=30)
|
||||||
|
const remote: RemoteConfig = {
|
||||||
|
agentlist: [{ agentid: 'A1', app_version: '49', app_download: 'apk_url', game_version: '10' }],
|
||||||
|
gamelist: [{
|
||||||
|
gameid: 'G1',
|
||||||
|
agentlist: [{ agentid: 'A1', app_version: '40', game_version: '30', game_download: 'g_url' }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const d: VersionDecision = VersionResolver.resolve(remote, keys);
|
||||||
|
expect(d.appVersion).assertEqual(49);
|
||||||
|
expect(d.appDownload).assertEqual('apk_url');
|
||||||
|
expect(d.gameVersion).assertEqual(30);
|
||||||
|
expect(d.gameDownload).assertEqual('g_url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no_match_returns_empty', 0, () => {
|
||||||
|
const remote: RemoteConfig = {
|
||||||
|
agentlist: [{ agentid: 'OTHER', game_version: '99', game_download: 'x' }],
|
||||||
|
};
|
||||||
|
const d: VersionDecision = VersionResolver.resolve(remote, keys);
|
||||||
|
expect(d.gameVersion).assertEqual(0);
|
||||||
|
expect(d.gameDownload).assertEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showmessage_from_layer_or_top', 0, () => {
|
||||||
|
const top: RemoteConfig = { showmessage: '停服维护公告' };
|
||||||
|
expect(VersionResolver.resolve(top, keys).showmessage).assertEqual('停服维护公告');
|
||||||
|
const layer: RemoteConfig = {
|
||||||
|
agentlist: [{ agentid: 'A1', showmessage: '代理层公告' }],
|
||||||
|
};
|
||||||
|
expect(VersionResolver.resolve(layer, keys).showmessage).assertEqual('代理层公告');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty_string_does_not_override', 0, () => {
|
||||||
|
// 深层空串不应覆盖浅层非空值
|
||||||
|
const remote: RemoteConfig = {
|
||||||
|
agentlist: [{
|
||||||
|
agentid: 'A1', game_download: 'keep', game_version: '10',
|
||||||
|
channellist: [{ channelid: 'C1', game_download: '' }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const d: VersionDecision = VersionResolver.resolve(remote, keys);
|
||||||
|
expect(d.gameDownload).assertEqual('keep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* "用时申请"统一入口:能力 Provider(定位/相机/麦克风/电话等,M3)在使用前调用。
|
* "用时申请"统一入口:能力 Provider(定位/相机/麦克风/电话等,M3)在使用前调用。
|
||||||
* 注:HarmonyOS 沙箱内文件读写无需存储权限——故启动期不再申请存储权限(与 Android 不同)。
|
* 注:HarmonyOS 沙箱内文件读写无需存储权限——故启动期不再申请存储权限(与 Android 不同)。
|
||||||
*/
|
*/
|
||||||
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
|
import { abilityAccessCtrl, common, Permissions, PermissionRequestResult } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { Logger } from 'common';
|
import { Logger } from 'common';
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export class PermissionGuard {
|
|||||||
}
|
}
|
||||||
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
|
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
|
||||||
try {
|
try {
|
||||||
const result: abilityAccessCtrl.PermissionRequestResult =
|
const result: PermissionRequestResult =
|
||||||
await atManager.requestPermissionsFromUser(context, perms);
|
await atManager.requestPermissionsFromUser(context, perms);
|
||||||
const authResults: number[] = result.authResults;
|
const authResults: number[] = result.authResults;
|
||||||
const allGranted: boolean = authResults.length > 0 && authResults.every((r: number) => r === 0);
|
const allGranted: boolean = authResults.length > 0 && authResults.every((r: number) => r === 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user