/** * 资源管理(契约 §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; /** 游戏目录(/,即 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 { /** 资源根绝对路径(= /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 子 ;缺失返回 ''),写入 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 按需下载)───────────────────────────────────────── /** 子游戏入口路径 `//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 顶层即 `/`,解压后得 `urlpath//...`。**原子化**:下载→解压暂存→校验 * `/index.html`→删旧换入,任一步失败旧资源保持不动。 */ async updateSubgame(dir: string, downloadUrl: string, onProgress?: ProgressCallback): Promise { 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 { 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 { // 单次读取:直接尝试拷贝,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//index.html → 校验通过才删旧 gamehall 并换入。 * 任一步失败(下载/解压/坏包/顶层缺 gamehall)旧资源保持不动,避免"删旧后解压失败把可用资源砖掉"。 */ async updateGame(downloadUrl: string, onProgress?: ProgressCallback): Promise { 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 顶层应含 /index.html) const stagedGame: string = `${staging}/${this.config.gamestart}`; if (!FileSystem.exists(`${stagedGame}/index.html`)) { throw new Error('updated package missing /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); } } }