M2(平台层): KvStore/FileSystem/HttpClient/Downloader/Unzipper/PermissionGuard/LocalUploadServer

T-M2-01 存储/文件:KvStore(preferences 持久化 urlpath/upurlpath)、FileSystem(fileIo 建删读写/列目录/拷贝内置 rawfile)
T-M2-02 网络/下载:HttpClient(GET 禁缓存,契约 §4.3)、Downloader(http 流式写盘+进度,原生异步不阻塞 UI)
T-M2-03 解压:Unzipper(zlib.decompressFile,原生异步)。偏差:下载/解压用原生异步 API 即满足"UI 不阻塞",
        独立 TaskScheduler(TaskPool) 推迟到 M5 真有 CPU 密集任务(MD5)时再加,避免投机性死代码。
T-M2-04 权限/上传:PermissionGuard(用时申请,沙箱文件免存储权限)、LocalUploadServer(骨架,本机端口服务 M3 落地)

devecocli build 通过。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-25 18:14:41 +08:00
parent bdb920f61e
commit ecb0e4642a
8 changed files with 363 additions and 3 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* 文件系统工具(框架 §8.3 / 附录 A.4)。封装 @kit.CoreFileKit fileIo + resourceManager。
*
* 供 ResourceManager / AppDataInjector 使用:建目录、读写文本、递归删除、列目录、
* 把随包内置 rawfile(内置预置包 zip)拷贝到沙箱。纯原生内部 IO,H5 无感知。
*/
import { fileIo as fs } from '@kit.CoreFileKit';
import { resourceManager } from '@kit.LocalizationKit';
import { Logger } from 'common';
export class FileSystem {
private static readonly log: Logger = Logger.tag('FileSystem');
/** 路径是否存在。 */
static exists(path: string): boolean {
try {
return fs.accessSync(path);
} catch (e) {
const err = e as Error;
FileSystem.log.w(`accessSync(${path}) failed: ${err.message}`);
return false;
}
}
/** 递归创建目录(已存在则忽略)。 */
static ensureDir(path: string): void {
if (FileSystem.exists(path)) {
return;
}
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);
}
}
/** 写文本(覆盖;自动建父目录)。 */
static writeText(path: string, content: string): void {
FileSystem.ensureDir(FileSystem.dirOf(path));
const file: fs.File = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC);
try {
fs.writeSync(file.fd, content);
} finally {
fs.closeSync(file);
}
}
/** 读文本(不存在返回空串)。 */
static readText(path: string): string {
if (!FileSystem.exists(path)) {
return '';
}
return fs.readTextSync(path);
}
/** 列目录条目名(不存在返回空数组)。 */
static listDir(path: string): string[] {
if (!FileSystem.exists(path)) {
return [];
}
return fs.listFileSync(path);
}
/** 把随包内置 rawfile 拷贝到沙箱目标路径(用于内置预置包 zip)。 */
static copyRawFileTo(resMgr: resourceManager.ResourceManager, rawFilePath: string, destPath: string): void {
const bytes: Uint8Array = resMgr.getRawFileContentSync(rawFilePath);
FileSystem.ensureDir(FileSystem.dirOf(destPath));
const file: fs.File = fs.openSync(destPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC);
try {
// ArkTSUint8Array.buffer 是底层 ArrayBufferwriteSync 接受 ArrayBuffer
fs.writeSync(file.fd, bytes.buffer as ArrayBuffer);
} finally {
fs.closeSync(file);
}
}
/** 取父目录路径(无分隔符则返回原串)。 */
static dirOf(path: string): string {
const idx: number = path.lastIndexOf('/');
return idx > 0 ? path.substring(0, idx) : path;
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 文件下载器(框架 §8.3 / §9)。封装 @kit.NetworkKit http 流式请求 requestInStream。
*
* 边收边写盘(不全量入内存,支持大 zip),经回调汇报百分比进度。
* http 流式请求为**原生异步、不阻塞 UI 线程**——故无需 TaskPool 即满足"下载不阻塞 UI"。
* 进度回调在 UI 线程触发(http 回调即在调用线程),调用方可直接更新 UI。
*/
import { http } from '@kit.NetworkKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { FileSystem } from '../fs/FileSystem';
import { Logger } from 'common';
/** 下载进度回调:percent 为 0~100 整数(总长未知时回 -1)。 */
export type ProgressCallback = (percent: number) => void;
export class Downloader {
private static readonly log: Logger = Logger.tag('Downloader');
/** 下载 url 到 savePath(覆盖旧文件),可选进度回调。失败抛出。 */
static async download(url: string, savePath: string, onProgress?: ProgressCallback): Promise<void> {
FileSystem.ensureDir(FileSystem.dirOf(savePath));
FileSystem.rmrf(savePath);
const file: fs.File = fs.openSync(savePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC);
let offset: number = 0;
const req: http.HttpRequest = http.createHttp();
req.on('dataReceive', (chunk: ArrayBuffer) => {
fs.writeSync(file.fd, chunk, { offset });
offset += chunk.byteLength;
});
if (onProgress !== undefined) {
req.on('dataReceiveProgress', (info: http.DataReceiveProgressInfo) => {
const pct: number = info.totalSize > 0
? Math.floor(info.receiveSize * 100 / info.totalSize) : -1;
onProgress(pct);
});
}
try {
const code: number = await req.requestInStream(url, {
method: http.RequestMethod.GET,
usingCache: false,
connectTimeout: 15000,
readTimeout: 300000,
});
if (code < 200 || code >= 300) {
throw new Error(`download http ${code}`);
}
Downloader.log.i(`download ${url} -> ${savePath} (${offset} bytes)`);
} finally {
req.off('dataReceive');
if (onProgress !== undefined) {
req.off('dataReceiveProgress');
}
req.destroy();
fs.closeSync(file);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* HTTP 客户端(框架 §8.2 / 附录 A.4)。封装 @kit.NetworkKit http。
*
* 用于远程配置请求:GET、**强制禁缓存**usingCache:false,对齐契约 §4.3 "禁缓存 + ?a=时间戳")。
* 明文 HTTP 域名须在 module.json5 网络安全配置放行(附录 B)。
*/
import { http } from '@kit.NetworkKit';
import { Logger } from 'common';
/** HTTP 文本响应结果。 */
export interface HttpResult {
/** HTTP 状态码(如 200)。 */
code: number;
/** 响应体文本(非字符串时为空串)。 */
body: string;
}
export class HttpClient {
private static readonly log: Logger = Logger.tag('HttpClient');
/** GET 文本,禁缓存。失败抛出(由调用方降级到本地缓存)。 */
static async getString(url: string, timeoutMs: number = 15000): Promise<HttpResult> {
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.GET,
expectDataType: http.HttpDataType.STRING,
usingCache: false,
connectTimeout: timeoutMs,
readTimeout: timeoutMs,
});
const body: string = typeof resp.result === 'string' ? resp.result : '';
HttpClient.log.i(`GET ${url} -> ${resp.responseCode} (${body.length} chars)`);
return { code: resp.responseCode, body };
} finally {
req.destroy();
}
}
}
@@ -0,0 +1,36 @@
/**
* 权限守卫(框架 §10 / 附录 B)。封装 @kit.AbilityKit abilityAccessCtrl。
*
* "用时申请"统一入口:能力 Provider(定位/相机/麦克风/电话等,M3)在使用前调用。
* 注:HarmonyOS 沙箱内文件读写无需存储权限——故启动期不再申请存储权限(与 Android 不同)。
*/
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { Logger } from 'common';
export class PermissionGuard {
private static readonly log: Logger = Logger.tag('PermissionGuard');
/**
* 申请一组权限,全部授予返回 true。
* @param context UIAbilityContext(用于拉起系统弹窗)
*/
static async request(context: common.UIAbilityContext, perms: Permissions[]): Promise<boolean> {
if (perms.length === 0) {
return true;
}
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
try {
const result: abilityAccessCtrl.PermissionRequestResult =
await atManager.requestPermissionsFromUser(context, perms);
const authResults: number[] = result.authResults;
const allGranted: boolean = authResults.length > 0 && authResults.every((r: number) => r === 0);
PermissionGuard.log.i(`request ${perms.join(',')} -> granted=${allGranted}`);
return allGranted;
} catch (e) {
const err = e as BusinessError;
PermissionGuard.log.e(`request failed: ${err.code} ${err.message}`);
return false;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 键值持久化(框架 §8.3 / 附录 A.4)。封装 @kit.ArkData preferences。
*
* 用途:持久化 `urlpath`(解压根)/`upurlpath`(父目录)等启动期路径,
* 供容器跨进程/重启后读取(对齐 Android SharedPreferences 用法)。
* H5 无感知——纯原生内部存储。
*/
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { Logger } from 'common';
/** 默认存储名(一个应用一个仓足够)。 */
const STORE_NAME: string = 'tsgame_kv';
export class KvStore {
private static readonly log: Logger = Logger.tag('KvStore');
private readonly store: preferences.Preferences;
private constructor(store: preferences.Preferences) {
this.store = store;
}
/** 在 UI/Ability 线程初始化(需 context)。失败抛出,由调用方降级。 */
static create(context: common.Context, name: string = STORE_NAME): KvStore {
const options: preferences.Options = { name };
const store: preferences.Preferences = preferences.getPreferencesSync(context, options);
return new KvStore(store);
}
getString(key: string, def: string = ''): string {
const v: preferences.ValueType = this.store.getSync(key, def);
return typeof v === 'string' ? v : def;
}
putString(key: string, value: string): void {
this.store.putSync(key, value);
}
getNumber(key: string, def: number = 0): number {
const v: preferences.ValueType = this.store.getSync(key, def);
return typeof v === 'number' ? v : def;
}
putNumber(key: string, value: number): void {
this.store.putSync(key, value);
}
has(key: string): boolean {
return this.store.hasSync(key);
}
delete(key: string): void {
this.store.deleteSync(key);
}
/** 落盘。putSync 仅写内存,需 flush 持久化。 */
async flush(): Promise<void> {
try {
await this.store.flush();
} catch (e) {
const err = e as Error;
KvStore.log.e(`flush failed: ${err.message}`);
}
}
}
@@ -0,0 +1,36 @@
/**
* 本机上传端点(契约 §7 / §9,框架 §6.3)。截图分享链路用:
* 容器在首次进度 100% 时把本机地址经桥 `setPostUrl` 推给 H5H5 截图后 POST 到此端点。
*
* ⚠ 当前为骨架:`baseUrl()` 返回占位地址供容器按契约推送 `setPostUrl`M2 无截图分享,
* 不会有实际上传)。**真正的本机 TCP/HTTP 服务在 M3 ShareProvider 落地**@ohos.net.socket),
* 届时 start() 绑定本机端口、接收 multipart 截图并落盘/转交分享 SDK。
*/
import { Logger } from 'common';
export class LocalUploadServer {
private static readonly log: Logger = Logger.tag('LocalUploadServer');
/** 占位端口,M3 实现真实服务时改为动态绑定后的实际端口。 */
private port: number = 8099;
private running: boolean = false;
/** 启动本机服务(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()}`);
}
async stop(): Promise<void> {
this.running = false;
}
isRunning(): boolean {
return this.running;
}
/** 推给 H5 的上传地址(对齐 Android `http://<本机IP>:<端口>/testurl`)。 */
baseUrl(): string {
return `http://127.0.0.1:${this.port}/testurl`;
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 解压器(框架 §8.3 / §9.1 / 附录 A.4)。封装 @kit.BasicServicesKit zlib.decompressFile。
*
* decompressFile 为**原生异步**——解压在系统侧执行、不阻塞 UI 线程,满足"解压不卡 UI"。
* 契约 §5.4:远程 zip 顶层即含 gamehall/...,解压到 <解压根>/ 即可。
*/
import { zlib } from '@kit.BasicServicesKit';
import { FileSystem } from '../fs/FileSystem';
import { Logger } from 'common';
export class Unzipper {
private static readonly log: Logger = Logger.tag('Unzipper');
/** 解压 zipPath 到 destDir(自动建目标目录)。失败抛出。 */
static async unzip(zipPath: string, destDir: string): Promise<void> {
FileSystem.ensureDir(destDir);
const options: zlib.Options = {};
await zlib.decompressFile(zipPath, destDir, options);
Unzipper.log.i(`unzip ${zipPath} -> ${destDir}`);
}
}