根因:整包 gamehall.zip 顶层只含 gamehall,子游戏需按需单独下载;原 switchGame 直接 loadUrl 不存在的目录 → ArkWeb invalidFileUrl → 白屏。 对齐 Android NewwebviewActivity 的 updategamezip: - ConfigManager.resolveGame(remote, gameid):以 gamedownloadurl 为 gameid 经 agent→game→channel→market 分层解析子游戏 game_zip/game_version(复用 VersionResolver)。 - ResourceManager.updateSubgame:下载→暂存解压→校验 <dir>/index.html→原子换入 urlpath/<dir>。 另加 subgameInstalled/subgameLocalVersion/subgameLocalGameId。 - BridgeGameContainer.switchGame 改 async + prepareSubgame:目录缺失先下载、已装比对远程 版本更高才更新;下载期复用启动页同款金色进度条;远程配置 10 分钟缓存(对齐 gameconfigtime)。 - SwitchGamePayload 增 gameId(gamedownloadurl)。 真机验证:点三个老K → 下载 sangelaok.zip(9.4M) → 解压加载,子游戏正常渲染 (V1.155 与配置 game_version=155 一致);日志不再 invalidFileUrl。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
9.2 KiB
Plaintext
218 lines
9.2 KiB
Plaintext
/**
|
||
* 资源管理(契约 §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, VersionXmlInfo } 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';
|
||
/** 资源根目录名(沙箱 filesDir 下)。容器的 file:// 跨域白名单也用它,单一来源避免漂移。 */
|
||
export const RESOURCE_ROOT_DIRNAME: string = 'tsgames';
|
||
|
||
export class ResourceManager {
|
||
/** 资源根绝对路径(= <filesDir>/tsgames)。供容器 setPathAllowingUniversalAccess 复用,杜绝路径重复定义。 */
|
||
static resourceRoot(context: common.Context): string {
|
||
return `${context.filesDir}/${RESOURCE_ROOT_DIRNAME}`;
|
||
}
|
||
|
||
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 = ResourceManager.resourceRoot(this.context);
|
||
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(取首个存在的候选;缺失 → 默认空信息)。 */
|
||
private readVersionXml(): VersionXmlInfo {
|
||
const candidates: string[] = [
|
||
`${this.cachedPaths.gameDir}/version.xml`,
|
||
`${this.cachedPaths.urlpath}/version.xml`,
|
||
];
|
||
for (const p of candidates) {
|
||
if (FileSystem.exists(p)) {
|
||
return VersionXml.parse(FileSystem.readText(p));
|
||
}
|
||
}
|
||
return { version: 0, gameName: '', gameId: '' };
|
||
}
|
||
|
||
/** 本地资源版本(version.xml 的 version;缺失返回 0)。 */
|
||
localVersion(): number {
|
||
return this.readVersionXml().version;
|
||
}
|
||
|
||
/** 本地游戏名(version.xml 子 <game name>;缺失返回 ''),写入 app_data 的 app_gamename。 */
|
||
localGameName(): string {
|
||
return this.readVersionXml().gameName;
|
||
}
|
||
|
||
/** 本地 version.xml 的 game id(大厅 app_config.gameid 为空时的回退匹配 key;缺失返回 '')。 */
|
||
localGameId(): string {
|
||
return this.readVersionXml().gameId;
|
||
}
|
||
|
||
// ── 子游戏(§11.1 路径 A 按需下载)─────────────────────────────────────────
|
||
|
||
/** 子游戏入口路径 `<urlpath>/<dir>/index.html`。 */
|
||
subgameIndexPath(dir: string): string {
|
||
return `${this.cachedPaths.urlpath}/${dir}/index.html`;
|
||
}
|
||
|
||
/** 子游戏资源是否就绪(index.html 存在)。 */
|
||
subgameInstalled(dir: string): boolean {
|
||
return FileSystem.exists(this.subgameIndexPath(dir));
|
||
}
|
||
|
||
/** 子游戏本地 version.xml 信息(缺失 → 空信息)。 */
|
||
private subgameVersionXml(dir: string): VersionXmlInfo {
|
||
const p: string = `${this.cachedPaths.urlpath}/${dir}/version.xml`;
|
||
return FileSystem.exists(p) ? VersionXml.parse(FileSystem.readText(p)) : { version: 0, gameName: '', gameId: '' };
|
||
}
|
||
|
||
/** 子游戏本地版本号(缺失 0)。 */
|
||
subgameLocalVersion(dir: string): number {
|
||
return this.subgameVersionXml(dir).version;
|
||
}
|
||
|
||
/** 子游戏本地 version.xml 的 game id(查更新时优先用它解析远程版本;缺失 '')。 */
|
||
subgameLocalGameId(dir: string): string {
|
||
return this.subgameVersionXml(dir).gameId;
|
||
}
|
||
|
||
/**
|
||
* 下载并安装子游戏 zip 到 `urlpath`(复刻 Android runzip:删旧目录 → 解压到 urlpath)。
|
||
* zip 顶层即 `<dir>/`,解压后得 `urlpath/<dir>/...`。**原子化**:下载→解压暂存→校验
|
||
* `<dir>/index.html`→删旧换入,任一步失败旧资源保持不动。
|
||
*/
|
||
async updateSubgame(dir: string, 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()}_${dir}.zip`;
|
||
const staging: string = `${this.cachedPaths.urlpath}/.staging_${dir}_${Date.now()}`;
|
||
const target: string = `${this.cachedPaths.urlpath}/${dir}`;
|
||
try {
|
||
await Downloader.download(url, zipPath, onProgress);
|
||
FileSystem.rmrf(staging);
|
||
FileSystem.ensureDir(staging);
|
||
await Unzipper.unzip(zipPath, staging);
|
||
const staged: string = `${staging}/${dir}`;
|
||
if (!FileSystem.exists(`${staged}/index.html`)) {
|
||
throw new Error(`subgame package missing ${dir}/index.html`);
|
||
}
|
||
FileSystem.rmrf(target);
|
||
FileSystem.ensureDir(this.cachedPaths.urlpath);
|
||
FileSystem.rename(staged, target);
|
||
ResourceManager.log.i(`subgame ${dir} updated from ${url}`);
|
||
} finally {
|
||
FileSystem.rmrf(zipPath);
|
||
FileSystem.rmrf(staging);
|
||
}
|
||
}
|
||
|
||
/** 持久化路径到 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> {
|
||
// 单次读取:直接尝试拷贝,rawfile 缺失即抛错 → 跳过(避免"先整包读探测、再整包读拷贝"两次入内存)
|
||
const tmpZip: string = `${this.cachedPaths.upurlpath}/builtin.zip`;
|
||
try {
|
||
FileSystem.copyRawFileTo(this.context.resourceManager, BUILTIN_ZIP_RAWFILE, tmpZip);
|
||
} catch (e) {
|
||
ResourceManager.log.w(`no builtin package (${BUILTIN_ZIP_RAWFILE}); rely on remote download`);
|
||
return;
|
||
}
|
||
FileSystem.ensureDir(this.cachedPaths.urlpath);
|
||
await Unzipper.unzip(tmpZip, this.cachedPaths.urlpath);
|
||
FileSystem.rmrf(tmpZip);
|
||
ResourceManager.log.i('builtin package prepared');
|
||
}
|
||
|
||
/**
|
||
* 从远程下载 zip 并更新游戏资源(契约 §5.4)。**原子化**:
|
||
* 下载 → 解压到暂存目录 → 校验 staging/<gamestart>/index.html → 校验通过才删旧 gamehall 并换入。
|
||
* 任一步失败(下载/解压/坏包/顶层缺 gamehall)旧资源保持不动,避免"删旧后解压失败把可用资源砖掉"。
|
||
*/
|
||
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`;
|
||
const staging: string = `${this.cachedPaths.urlpath}/.staging_${Date.now()}`;
|
||
try {
|
||
await Downloader.download(url, zipPath, onProgress);
|
||
FileSystem.rmrf(staging);
|
||
FileSystem.ensureDir(staging);
|
||
await Unzipper.unzip(zipPath, staging);
|
||
// 校验新包顶层结构(远程 zip 顶层应含 <gamestart>/index.html)
|
||
const stagedGame: string = `${staging}/${this.config.gamestart}`;
|
||
if (!FileSystem.exists(`${stagedGame}/index.html`)) {
|
||
throw new Error('updated package missing <gamestart>/index.html');
|
||
}
|
||
// 原子换入:旧资源直到此刻仍可用
|
||
FileSystem.rmrf(this.cachedPaths.gameDir);
|
||
FileSystem.ensureDir(this.cachedPaths.urlpath);
|
||
FileSystem.rename(stagedGame, this.cachedPaths.gameDir);
|
||
ResourceManager.log.i(`game updated from ${url}`);
|
||
} finally {
|
||
FileSystem.rmrf(zipPath);
|
||
FileSystem.rmrf(staging);
|
||
}
|
||
}
|
||
}
|