review: M2 成果审查后的质量加固
三路+对照审查 M2 全部成果。桥核心(M1,本期未改)与契约骨架零问题;发现集中在新的资源/启动/容器代码,已修: 高危 - ResourceManager.updateGame 原子化:下载→解压暂存→校验 <gamestart>/index.html→才删旧并 rename 换入。 修复"先删旧 gamehall 再解压,失败即把可用资源砖掉"且 catch『keep local』失效的数据丢失风险 - BridgeGameContainer.setupBridge:先建桥并赋值 this.bridge,能力注册/配置加载整体兜底 try/catch。 修复"config/registration 抛错→bridge 永远 undefined→H5 所有 callHandler 静默"违反 H5 零改动铁律 中危 - VersionResolver:空 gameid 守卫(大厅不参与 game 树匹配,避免误命中 gamelist 空 id 节点); 两树 version 平手时取"有下载地址"的一棵(避免选中只有版本号无下载地址的层)。新增 2 单测,覆盖率 83.7% - Downloader:dataReceive 写盘失败标记并于请求结束后抛出(避免磁盘满致截断文件被当作下载成功) - FileSystem:rmrf 的 statSync 纳入 try(兑现"不存在/竞态则忽略"承诺);copyRawFileTo 按 byteOffset/byteLength 切片写入(避免 Uint8Array 为大池子视图时写入多余字节致 zip 损坏);新增 rename - ConfigManager.fetchSecondary:二级节点本层版本字段也并入(此前只换 channellist/agentlist 会丢节点自身版本/下载地址) 清理/抗回归 - 跨域白名单路径改用 ResourceManager.resourceRoot()(单一来源,避免与领域层路径定义漂移致 V2 回归) - 前后台订阅移到 setupBridge(桥就位后),避免冷启动 FOREGROUND 早于桥到达被丢 - prepareBuiltin 单次读取内置包(去掉"先整包读探测、再整包读拷贝"双读) - LocalUploadServer 删死码 isRunning/running - 文档化延期项:weburl 分支不注入 app_data(同 Android,远程大厅自带)、app_gamename 待 M3 扩展 VersionXml、 子游戏切换 firstProgressDone 复位 TODO(M3 T-M3-06) devecocli build 通过;单测全通过;模拟器复测 app_data/V2 fetch/桥双向/特殊字符往返均无回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
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) {
|
||||
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<RemoteConfig | undefined> {
|
||||
try {
|
||||
const res = await HttpClient.getString(url);
|
||||
|
||||
@@ -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 {
|
||||
/** 资源根绝对路径(= <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;
|
||||
@@ -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<void> {
|
||||
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/<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);
|
||||
await Unzipper.unzip(zipPath, this.cachedPaths.urlpath);
|
||||
FileSystem.rmrf(zipPath);
|
||||
FileSystem.rename(stagedGame, this.cachedPaths.gameDir);
|
||||
ResourceManager.log.i(`game updated from ${url}`);
|
||||
} finally {
|
||||
FileSystem.rmrf(zipPath);
|
||||
FileSystem.rmrf(staging);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 的 <game name>,当前 VersionXml 仅解析 version;
|
||||
// H5 若读 app_gamename 暂为空串。需要时扩展 VersionXml 用 token 回调按标签上下文取 name 属性。
|
||||
app_gamename: '',
|
||||
app_getwifisignalLevel: '0',
|
||||
};
|
||||
|
||||
@@ -68,17 +68,23 @@ export class VersionResolver {
|
||||
return acc;
|
||||
}
|
||||
merge(acc, market);
|
||||
// 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),
|
||||
|
||||
@@ -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,17 +72,23 @@ 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));
|
||||
this.adapter = adapter;
|
||||
this.bridge = bridge;
|
||||
|
||||
try {
|
||||
bridge.setBridgeJs(BridgeJsLoader.load(hostCtx));
|
||||
const ctx: CapabilityContext = {
|
||||
uiAbilityContext: hostCtx,
|
||||
config: ConfigManager.load(hostCtx),
|
||||
@@ -88,10 +98,13 @@ export struct BridgeGameContainer {
|
||||
};
|
||||
const registrar = new CapabilityRegistrar(buildCapabilities());
|
||||
registrar.registerAll(bridge, ctx);
|
||||
|
||||
this.adapter = adapter;
|
||||
this.bridge = bridge;
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,17 +30,27 @@ export class FileSystem {
|
||||
fs.mkdirSync(path, true);
|
||||
}
|
||||
|
||||
/** 递归删除文件或目录(不存在则忽略)。 */
|
||||
/** 递归删除文件或目录(不存在/竞态则忽略,绝不抛错——对齐注释承诺)。 */
|
||||
static rmrf(path: string): void {
|
||||
if (!FileSystem.exists(path)) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** 写文本(覆盖;自动建父目录)。 */
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
// 写盘失败(磁盘满等)发生在事件回调内,不会拒绝 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');
|
||||
|
||||
@@ -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<void> {
|
||||
// TODO(M3): @ohos.net.socket 绑定 127.0.0.1:<port>,接收截图上传
|
||||
this.running = true;
|
||||
LocalUploadServer.log.i(`start (stub) baseUrl=${this.baseUrl()}`);
|
||||
}
|
||||
|
||||
/** 停止本机服务(M3 实现真实服务后关闭 socket)。 */
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
// TODO(M3): 关闭 socket
|
||||
}
|
||||
|
||||
/** 推给 H5 的上传地址(对齐 Android `http://<本机IP>:<端口>/testurl`)。 */
|
||||
|
||||
Reference in New Issue
Block a user