diff --git a/platform/Index.ets b/platform/Index.ets index e3f586b..9091486 100644 --- a/platform/Index.ets +++ b/platform/Index.ets @@ -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'; diff --git a/platform/src/main/ets/fs/FileSystem.ets b/platform/src/main/ets/fs/FileSystem.ets new file mode 100644 index 0000000..53cffdd --- /dev/null +++ b/platform/src/main/ets/fs/FileSystem.ets @@ -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; + } +} diff --git a/platform/src/main/ets/net/Downloader.ets b/platform/src/main/ets/net/Downloader.ets new file mode 100644 index 0000000..a1d30c3 --- /dev/null +++ b/platform/src/main/ets/net/Downloader.ets @@ -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 { + 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); + } + } +} diff --git a/platform/src/main/ets/net/HttpClient.ets b/platform/src/main/ets/net/HttpClient.ets new file mode 100644 index 0000000..835f3d9 --- /dev/null +++ b/platform/src/main/ets/net/HttpClient.ets @@ -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 { + 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(); + } + } +} diff --git a/platform/src/main/ets/perm/PermissionGuard.ets b/platform/src/main/ets/perm/PermissionGuard.ets new file mode 100644 index 0000000..c058ec6 --- /dev/null +++ b/platform/src/main/ets/perm/PermissionGuard.ets @@ -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 { + 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; + } + } +} diff --git a/platform/src/main/ets/storage/KvStore.ets b/platform/src/main/ets/storage/KvStore.ets new file mode 100644 index 0000000..cbe94f9 --- /dev/null +++ b/platform/src/main/ets/storage/KvStore.ets @@ -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 { + try { + await this.store.flush(); + } catch (e) { + const err = e as Error; + KvStore.log.e(`flush failed: ${err.message}`); + } + } +} diff --git a/platform/src/main/ets/upload/LocalUploadServer.ets b/platform/src/main/ets/upload/LocalUploadServer.ets new file mode 100644 index 0000000..2f2f71a --- /dev/null +++ b/platform/src/main/ets/upload/LocalUploadServer.ets @@ -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 { + // TODO(M3): @ohos.net.socket 绑定 127.0.0.1:,接收截图上传 + this.running = true; + LocalUploadServer.log.i(`start (stub) baseUrl=${this.baseUrl()}`); + } + + async stop(): Promise { + this.running = false; + } + + isRunning(): boolean { + return this.running; + } + + /** 推给 H5 的上传地址(对齐 Android `http://<本机IP>:<端口>/testurl`)。 */ + baseUrl(): string { + return `http://127.0.0.1:${this.port}/testurl`; + } +} diff --git a/platform/src/main/ets/zip/Unzipper.ets b/platform/src/main/ets/zip/Unzipper.ets new file mode 100644 index 0000000..29c34db --- /dev/null +++ b/platform/src/main/ets/zip/Unzipper.ets @@ -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 { + FileSystem.ensureDir(destDir); + const options: zlib.Options = {}; + await zlib.decompressFile(zipPath, destDir, options); + Unzipper.log.i(`unzip ${zipPath} -> ${destDir}`); + } +}