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:
+16
-3
@@ -1,3 +1,16 @@
|
||||
// platform HAR —— 平台服务层(导出入口 SSOT)
|
||||
// 各子模块实现完成后在此统一 export。占位常量确保 HAR 可编译。
|
||||
export const platform_MODULE_VERSION: string = '1.0.0';
|
||||
// platform HAR —— 平台服务层(导出入口 SSOT,框架 §6 平台服务)
|
||||
|
||||
// 存储 / 文件
|
||||
export { KvStore } from './src/main/ets/storage/KvStore';
|
||||
export { FileSystem } from './src/main/ets/fs/FileSystem';
|
||||
|
||||
// 网络 / 下载
|
||||
export { HttpClient, HttpResult } from './src/main/ets/net/HttpClient';
|
||||
export { Downloader, ProgressCallback } from './src/main/ets/net/Downloader';
|
||||
|
||||
// 解压
|
||||
export { Unzipper } from './src/main/ets/zip/Unzipper';
|
||||
|
||||
// 权限 / 上传服务
|
||||
export { PermissionGuard } from './src/main/ets/perm/PermissionGuard';
|
||||
export { LocalUploadServer } from './src/main/ets/upload/LocalUploadServer';
|
||||
|
||||
@@ -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 {
|
||||
// ArkTS:Uint8Array.buffer 是底层 ArrayBuffer,writeSync 接受 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 推给 H5,H5 截图后 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`;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user