Files
youle_app_ohos/domain_resource/src/main/ets/resource/ResourceManager.ets
T
lanterngamescnandClaude Opus 4.8 de5b950d9b 实现子游戏按需下载,修复点子游戏白屏(§11.1 路径A)
根因:整包 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>
2026-06-27 02:33:55 +08:00

218 lines
9.2 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 资源管理(契约 §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);
}
}
}