diff --git a/domain_resource/src/main/ets/config/ConfigManager.ets b/domain_resource/src/main/ets/config/ConfigManager.ets index 31bce68..9d770e8 100644 --- a/domain_resource/src/main/ets/config/ConfigManager.ets +++ b/domain_resource/src/main/ets/config/ConfigManager.ets @@ -10,7 +10,7 @@ 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 { AgentNode, GameTreeNode, MatchKeys, RemoteConfig, VersionDecision, VersionFields } from './RemoteConfig'; import { VersionResolver } from '../version/VersionResolver'; /** 本地配置 rawfile 文件名。 */ @@ -98,8 +98,12 @@ export class ConfigManager { 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; + if (subAgent !== undefined) { + // 既并入子树,也并入二级节点本层的版本字段(此前只换 channellist 会丢节点自身的版本/下载地址) + ConfigManager.copyVersionFields(agent, subAgent); + if (subAgent.channellist !== undefined) { + agent.channellist = subAgent.channellist; + } ConfigManager.log.i('secondary agent config merged'); } } @@ -110,13 +114,41 @@ export class ConfigManager { 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; + if (subGame !== undefined) { + ConfigManager.copyVersionFields(game, subGame); + if (subGame.agentlist !== undefined) { + game.agentlist = subGame.agentlist; + } ConfigManager.log.i('secondary game config merged'); } } } + /** 用 src 的非空版本字段覆盖 dst(二级配置节点本层的版本/下载地址不丢失)。 */ + private static copyVersionFields(dst: VersionFields, src: VersionFields): void { + if (src.app_version !== undefined && src.app_version !== '') { + dst.app_version = src.app_version; + } + if (src.app_download !== undefined && src.app_download !== '') { + dst.app_download = src.app_download; + } + if (src.app_size !== undefined && src.app_size !== '') { + dst.app_size = src.app_size; + } + if (src.game_version !== undefined && src.game_version !== '') { + dst.game_version = src.game_version; + } + if (src.game_download !== undefined && src.game_download !== '') { + dst.game_download = src.game_download; + } + if (src.game_size !== undefined && src.game_size !== '') { + dst.game_size = src.game_size; + } + if (src.showmessage !== undefined && src.showmessage !== '') { + dst.showmessage = src.showmessage; + } + } + private async tryFetchSub(url: string): Promise { try { const res = await HttpClient.getString(url); diff --git a/domain_resource/src/main/ets/resource/ResourceManager.ets b/domain_resource/src/main/ets/resource/ResourceManager.ets index e99cd47..9dfdd71 100644 --- a/domain_resource/src/main/ets/resource/ResourceManager.ets +++ b/domain_resource/src/main/ets/resource/ResourceManager.ets @@ -34,8 +34,15 @@ 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; @@ -50,7 +57,7 @@ export class ResourceManager { } private computePaths(): ResourcePaths { - const upurlpath: string = `${this.context.filesDir}/tsgames`; + 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`; @@ -92,18 +99,14 @@ export class ResourceManager { * 内置包顶层即含 gamehall/...,解压到 urlpath 即可。无内置包则跳过(依赖远程下载)。 */ async prepareBuiltin(): Promise { - let bytesAvailable: boolean = true; + // 单次读取:直接尝试拷贝,rawfile 缺失即抛错 → 跳过(避免"先整包读探测、再整包读拷贝"两次入内存) + const tmpZip: string = `${this.cachedPaths.upurlpath}/builtin.zip`; try { - this.context.resourceManager.getRawFileContentSync(BUILTIN_ZIP_RAWFILE); + FileSystem.copyRawFileTo(this.context.resourceManager, BUILTIN_ZIP_RAWFILE, tmpZip); } 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); @@ -111,17 +114,32 @@ export class ResourceManager { } /** - * 从远程下载 zip 并更新游戏资源(契约 §5.4): - * 下载 game_download(?a=ts) → 删旧 gamehall → 解压到 urlpath → 删 zip。 + * 从远程下载 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`; - 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}`); + 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); + } } } diff --git a/domain_resource/src/main/ets/startup/StartupOrchestrator.ets b/domain_resource/src/main/ets/startup/StartupOrchestrator.ets index 0fdf54d..88022d0 100644 --- a/domain_resource/src/main/ets/startup/StartupOrchestrator.ets +++ b/domain_resource/src/main/ets/startup/StartupOrchestrator.ets @@ -73,7 +73,10 @@ export class StartupOrchestrator { const kv: KvStore = KvStore.create(this.context); const resource: ResourceManager = new ResourceManager(this.context, local, kv); - // weburl 非空 → 远程 http 大厅,无需本地资源 + // weburl 非空 → 远程 http 大厅,无需本地资源。 + // 注:此分支不注入 app_data.js——与 Android 一致,远程大厅由其自身服务端提供 app_data.js/配置; + // 本仓库 weburl 为空走下方本地 file:// 分支(会注入)。若未来启用远程大厅且依赖原生注入的 + // app_data 全局变量,需在此补等价注入机制。 if (local.weburl !== '') { const entryUrl: string = `http://${local.weburl.replace(/-/g, '/')}?Launchtype=0`; this.stage(StartupStage.ENTER_HALL, 100); @@ -130,6 +133,8 @@ export class StartupOrchestrator { app_channel: local.channel, app_invitationcode: local.tuiguang, app_Launchtype: '0', + // TODO(M3):app_gamename 源为 version.xml 的 ,当前 VersionXml 仅解析 version; + // H5 若读 app_gamename 暂为空串。需要时扩展 VersionXml 用 token 回调按标签上下文取 name 属性。 app_gamename: '', app_getwifisignalLevel: '0', }; diff --git a/domain_resource/src/main/ets/version/VersionResolver.ets b/domain_resource/src/main/ets/version/VersionResolver.ets index 1db3dec..48dcd61 100644 --- a/domain_resource/src/main/ets/version/VersionResolver.ets +++ b/domain_resource/src/main/ets/version/VersionResolver.ets @@ -68,17 +68,23 @@ export class VersionResolver { return acc; } merge(acc, market); - const game: GameOverrideNode | undefined = - (market.gamelist ?? []).find((g: GameOverrideNode) => (g.gameid ?? '') === k.gameid); - if (game !== undefined) { - merge(acc, game); + // gameid 为空表示"不参与 game 维度匹配"(大厅);非空才在 market.gamelist 里命中 + if (k.gameid !== '') { + 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,逐层并入。 */ + /** 游戏树:gameid → agentid → channelid → marketid,逐层并入。gameid 为空则不参与游戏树。 */ static resolveGameTree(games: GameTreeNode[], k: MatchKeys): VersionFields { const acc: VersionFields = {}; + if (k.gameid === '') { + return acc; + } const game: GameTreeNode | undefined = games.find((g: GameTreeNode) => (g.gameid ?? '') === k.gameid); if (game === undefined) { return acc; @@ -111,11 +117,26 @@ export class VersionResolver { const aGameV: number = parseVer(a.game_version); const gGameV: number = parseVer(g.game_version); - const gameWin: VersionFields = gGameV > aGameV ? g : a; + // 取更高 version 者;平手时取"有下载地址"的一棵(避免选中只有版本号无下载地址的层) + let gameWin: VersionFields; + if (gGameV > aGameV) { + gameWin = g; + } else if (aGameV > gGameV) { + gameWin = a; + } else { + gameWin = (a.game_download !== undefined && a.game_download !== '') ? a : g; + } const aAppV: number = parseVer(a.app_version); const gAppV: number = parseVer(g.app_version); - const appWin: VersionFields = gAppV > aAppV ? g : a; + let appWin: VersionFields; + if (gAppV > aAppV) { + appWin = g; + } else if (aAppV > gAppV) { + appWin = a; + } else { + appWin = (a.app_download !== undefined && a.app_download !== '') ? a : g; + } return { showmessage: firstNonEmpty(a.showmessage, g.showmessage, remote.showmessage), diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index 1bfc4e8..7b9ee35 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -2,7 +2,7 @@ import { webview } from '@kit.ArkWeb'; import { common } from '@kit.AbilityKit'; import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge'; import { CapabilityContext, CapabilityRegistrar } from 'feature_capabilities'; -import { ConfigManager } from 'domain_resource'; +import { ConfigManager, ResourceManager } from 'domain_resource'; import { KvStore, LocalUploadServer } from 'platform'; import { OutboundHandlers } from 'contracts'; import { AppEnv, EventBus, Logger } from 'common'; @@ -33,7 +33,11 @@ export struct BridgeGameContainer { if (AppEnv.isDebug()) { webview.WebviewController.setWebDebuggingAccess(true); } - // 前后台联动:EntryAbility 经 EventBus 广播 → 转给各能力 + callHandler('appservice') + // 前后台订阅移到 setupBridge(桥就位后),避免冷启动 FOREGROUND 早于桥到达被静默丢弃 + } + + /** 前后台联动订阅:EntryAbility 经 EventBus 广播 → 转给各能力 + callHandler('appservice')。 */ + private subscribeLifecycle(): void { this.cancelForeground = EventBus.on(AppEvents.FOREGROUND, () => { this.registrar?.forEachForeground(); this.bridge?.callHandler(OutboundHandlers.AppService, '1'); @@ -68,30 +72,39 @@ export struct BridgeGameContainer { // 🔴 V2 file:// 跨域(框架 §7.3):file:// 页面的 XHR/fetch 默认被 CORS 拦截(origin 'null')。 // 官方方案:把资源根加入"允许跨域访问"白名单,file:// 访问该路径下资源即放开同源限制。 // 对 H5 完全透明(仍 file:// 加载)。路径须在 filesDir 子目录且与用户文件隔离。 + // 跨域白名单路径取自 ResourceManager(单一来源,避免与领域层路径定义漂移致 V2 回归) try { - this.controller.setPathAllowingUniversalAccess([`${hostCtx.filesDir}/tsgames`]); + this.controller.setPathAllowingUniversalAccess([ResourceManager.resourceRoot(hostCtx)]); } catch (e) { const err = e as Error; Logger.tag('BridgeGameContainer').e(`setPathAllowingUniversalAccess failed: ${err.message}`); } + // 🔴 先建桥并就位:即使下面能力注册/配置加载抛错,桥仍可用——未注册 handler 落 DefaultHandler, + // H5 调用不报错(铁律最后防线)。桥失效才是真正违反"H5 调用永不报错"。 const adapter = new WebviewControllerAdapter(this.controller); const bridge = new BridgeController(adapter); - bridge.setBridgeJs(BridgeJsLoader.load(hostCtx)); - - const ctx: CapabilityContext = { - uiAbilityContext: hostCtx, - config: ConfigManager.load(hostCtx), - kv: KvStore.create(hostCtx), - uploadServer: this.uploadServer, - log: Logger.tag('Capability'), - }; - const registrar = new CapabilityRegistrar(buildCapabilities()); - registrar.registerAll(bridge, ctx); - this.adapter = adapter; this.bridge = bridge; - this.registrar = registrar; + + try { + bridge.setBridgeJs(BridgeJsLoader.load(hostCtx)); + const ctx: CapabilityContext = { + uiAbilityContext: hostCtx, + config: ConfigManager.load(hostCtx), + kv: KvStore.create(hostCtx), + uploadServer: this.uploadServer, + log: Logger.tag('Capability'), + }; + const registrar = new CapabilityRegistrar(buildCapabilities()); + 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}`); + } + + this.subscribeLifecycle(); this.uploadServer.start(); } @@ -124,7 +137,10 @@ export struct BridgeGameContainer { this.bridge?.onPageEnd(); }) .onProgressChange((event: OnProgressChangeEvent) => { - // 首次进度 100% → 推 appservice(前台) + setPostUrl(与 Android onProgressChanged==100 一致) + // 首次进度 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) { this.firstProgressDone = true; const b = this.bridge; diff --git a/entry/src/test/VersionResolver.test.ets b/entry/src/test/VersionResolver.test.ets index 826aa51..05a5de6 100644 --- a/entry/src/test/VersionResolver.test.ets +++ b/entry/src/test/VersionResolver.test.ets @@ -111,5 +111,33 @@ export default function versionResolverTest() { const d: VersionDecision = VersionResolver.resolve(remote, keys); expect(d.gameDownload).assertEqual('keep'); }); + + it('empty_gameid_skips_game_tree', 0, () => { + // 大厅 gameid='' 不应误命中 gamelist 中 gameid 为空的节点 + const lobbyKeys: MatchKeys = { agentid: 'A1', channelid: 'C1', marketid: 'M1', gameid: '' }; + const remote: RemoteConfig = { + gamelist: [{ gameid: '', game_version: '99', game_download: 'should_not_match', + agentlist: [{ agentid: 'A1', game_version: '99', game_download: 'should_not_match' }] }], + agentlist: [{ agentid: 'A1', game_version: '10', game_download: 'agent_only', + channellist: [{ channelid: 'C1', + marketlist: [{ marketid: 'M1', + gamelist: [{ gameid: '', game_version: '88', game_download: 'should_not_match' }] }] }] }], + }; + const d: VersionDecision = VersionResolver.resolve(remote, lobbyKeys); + expect(d.gameVersion).assertEqual(10); + expect(d.gameDownload).assertEqual('agent_only'); + }); + + it('tie_prefers_nonempty_download', 0, () => { + // 两树 game_version 相等(20),代理树只有版本号无下载地址 → 应取游戏树的下载地址 + const remote: RemoteConfig = { + agentlist: [{ agentid: 'A1', game_version: '20' }], + gamelist: [{ gameid: 'G1', + agentlist: [{ agentid: 'A1', game_version: '20', game_download: 'game_url' }] }], + }; + const d: VersionDecision = VersionResolver.resolve(remote, keys); + expect(d.gameVersion).assertEqual(20); + expect(d.gameDownload).assertEqual('game_url'); + }); }); } diff --git a/platform/src/main/ets/fs/FileSystem.ets b/platform/src/main/ets/fs/FileSystem.ets index 53cffdd..ea9f6f2 100644 --- a/platform/src/main/ets/fs/FileSystem.ets +++ b/platform/src/main/ets/fs/FileSystem.ets @@ -30,19 +30,29 @@ export class FileSystem { fs.mkdirSync(path, true); } - /** 递归删除文件或目录(不存在则忽略)。 */ + /** 递归删除文件或目录(不存在/竞态则忽略,绝不抛错——对齐注释承诺)。 */ static rmrf(path: string): void { if (!FileSystem.exists(path)) { return; } - const stat: fs.Stat = fs.statSync(path); - if (stat.isDirectory()) { - fs.rmdirSync(path); - } else { - fs.unlinkSync(path); + try { + const stat: fs.Stat = fs.statSync(path); + if (stat.isDirectory()) { + fs.rmdirSync(path); + } else { + fs.unlinkSync(path); + } + } catch (e) { + const err = e as Error; + FileSystem.log.w(`rmrf(${path}) ignored: ${err.message}`); } } + /** 重命名/移动(同文件系统)。用于资源原子换入。 */ + static rename(oldPath: string, newPath: string): void { + fs.renameSync(oldPath, newPath); + } + /** 写文本(覆盖;自动建父目录)。 */ static writeText(path: string, content: string): void { FileSystem.ensureDir(FileSystem.dirOf(path)); @@ -76,8 +86,9 @@ export class FileSystem { FileSystem.ensureDir(FileSystem.dirOf(destPath)); const file: fs.File = fs.openSync(destPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); try { - // ArkTS:Uint8Array.buffer 是底层 ArrayBuffer,writeSync 接受 ArrayBuffer - fs.writeSync(file.fd, bytes.buffer as ArrayBuffer); + // 按 Uint8Array 的实际视图(byteOffset/byteLength)写入,避免底层 buffer 为大池子视图时写入多余字节致 zip 损坏 + const view: ArrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + fs.writeSync(file.fd, view); } finally { fs.closeSync(file); } diff --git a/platform/src/main/ets/net/Downloader.ets b/platform/src/main/ets/net/Downloader.ets index a1d30c3..752b66c 100644 --- a/platform/src/main/ets/net/Downloader.ets +++ b/platform/src/main/ets/net/Downloader.ets @@ -22,11 +22,20 @@ export class Downloader { FileSystem.rmrf(savePath); const file: fs.File = fs.openSync(savePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); let offset: number = 0; + let writeError: string = ''; const req: http.HttpRequest = http.createHttp(); req.on('dataReceive', (chunk: ArrayBuffer) => { - fs.writeSync(file.fd, chunk, { offset }); - offset += chunk.byteLength; + // 写盘失败(磁盘满等)发生在事件回调内,不会拒绝 requestInStream;记录后于结束时抛出,避免截断文件被当作成功 + if (writeError !== '') { + return; + } + try { + fs.writeSync(file.fd, chunk, { offset }); + offset += chunk.byteLength; + } catch (e) { + writeError = (e as Error).message; + } }); if (onProgress !== undefined) { req.on('dataReceiveProgress', (info: http.DataReceiveProgressInfo) => { @@ -46,6 +55,9 @@ export class Downloader { if (code < 200 || code >= 300) { throw new Error(`download http ${code}`); } + if (writeError !== '') { + throw new Error(`download write failed: ${writeError}`); + } Downloader.log.i(`download ${url} -> ${savePath} (${offset} bytes)`); } finally { req.off('dataReceive'); diff --git a/platform/src/main/ets/upload/LocalUploadServer.ets b/platform/src/main/ets/upload/LocalUploadServer.ets index 2f2f71a..5be39bb 100644 --- a/platform/src/main/ets/upload/LocalUploadServer.ets +++ b/platform/src/main/ets/upload/LocalUploadServer.ets @@ -12,21 +12,16 @@ export class LocalUploadServer { private static readonly log: Logger = Logger.tag('LocalUploadServer'); /** 占位端口,M3 实现真实服务时改为动态绑定后的实际端口。 */ private port: number = 8099; - private running: boolean = false; - /** 启动本机服务(M2 占位:仅置位,不绑定端口)。 */ + /** 启动本机服务(M2 占位:不绑定端口)。 */ async start(): Promise { // TODO(M3): @ohos.net.socket 绑定 127.0.0.1:,接收截图上传 - this.running = true; LocalUploadServer.log.i(`start (stub) baseUrl=${this.baseUrl()}`); } + /** 停止本机服务(M3 实现真实服务后关闭 socket)。 */ async stop(): Promise { - this.running = false; - } - - isRunning(): boolean { - return this.running; + // TODO(M3): 关闭 socket } /** 推给 H5 的上传地址(对齐 Android `http://<本机IP>:<端口>/testurl`)。 */