diff --git a/README.md b/README.md index f5a42b5..e41ddfe 100644 --- a/README.md +++ b/README.md @@ -207,19 +207,25 @@ Harmony 版本不提供本地安装包下载,更新只跳转应用市场。 ### 检查 RN Bundle 热更新 ```typescript -const rnResult = await XuqmSDK.update.checkRnUpdate('ak_xxx', 'main', 100) +const rnResult = await XuqmSDK.update.checkRnUpdate('ak_xxx', 'main') if (rnResult.hasUpdate && rnResult.info) { const filePath = await XuqmSDK.update.downloadRnBundle( context, rnResult.info.downloadUrl, - 'main.harmony.bundle' + 'main.harmony.zip', + 'main', + rnResult.info ) - // 文件下载至 context.cacheDir/main.harmony.bundle + // 文件下载至 context.cacheDir/main.harmony.zip + // 如需在本地缓存当前 RN 版本,downloadRnBundle 会顺手写入 bundleVersion // 通知 RN 引擎重新加载 } ``` +`checkRnUpdate` 会优先读取本地保存的 RN bundleVersion;如果本地没有缓存,再回退到调用方手工传入的版本号。 +如果项目里还有别的 bundle 安装入口,不经过 `downloadRnBundle`,那条链路也要在安装成功后调用 `XuqmSDK.update.rememberRnBundleInfo(...)`,否则本地缓存会落后于真实已安装版本。 + --- ## 发版(ohpm) diff --git a/xuqm-sdk/Index.ets b/xuqm-sdk/Index.ets index 9117528..338839c 100644 --- a/xuqm-sdk/Index.ets +++ b/xuqm-sdk/Index.ets @@ -10,6 +10,7 @@ export type { SendMessageParams, AppVersionInfo, RnBundleInfo, + InstalledRnBundleInfo, PushTokenInfo, MsgType, ChatType, diff --git a/xuqm-sdk/scripts/xuqm_rn_release.gradle.kts b/xuqm-sdk/scripts/xuqm_rn_release.gradle.kts new file mode 100644 index 0000000..121e76b --- /dev/null +++ b/xuqm-sdk/scripts/xuqm_rn_release.gradle.kts @@ -0,0 +1,262 @@ +/** + * XuqmGroup Update Service — Harmony RN Bundle Release Task + * + * Copy this file to your Harmony project and apply it: + * apply(from = "xuqm_rn_release.gradle.kts") + * + * Then run: + * ./hvigorw xuqmRnRelease + * + * Config: xuqm.properties in the project root (or module root) + * --- + * xuqm.serverUrl=https://update.dev.xuqinmin.com + * xuqm.appKey=your-app-key + * xuqm.apiToken=your-api-token + * xuqm.moduleId=main + * xuqm.bundleFile=/path/to/index.bundle + * xuqm.resourceDir=/path/to/resources # optional, but recommended + * xuqm.bundleVersion=100 + * xuqm.minCommonVersion=1.0.0 # optional + * xuqm.packageName=com.example.app # optional + * xuqm.note=optional note + * --- + * + * The task: + * 1. Reads local RN release metadata from xuqm.properties + * 2. Packages bundle + resources + rn-manifest.json into a zip + * 3. Uploads the zip to update-service so the server can auto-detect metadata + */ + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +fun loadXuqmConfig(projectDir: File): Properties { + val props = Properties() + listOf( + File(projectDir, "xuqm.properties"), + File(projectDir.parentFile, "xuqm.properties"), + ).firstOrNull { it.exists() }?.inputStream()?.use(props::load) + ?: throw GradleException("xuqm.properties not found in module or project root") + return props +} + +fun httpGet(url: String, token: String): String { + val client = HttpClient.newHttpClient() + val builder = HttpRequest.newBuilder(URI.create(url)) + if (token.isNotBlank()) { + builder.header("Authorization", "Bearer $token") + } + val req = builder.GET().build() + return client.send(req, HttpResponse.BodyHandlers.ofString()).body() +} + +fun httpMultipartPost(url: String, token: String, parts: Map): String { + val boundary = UUID.randomUUID().toString() + val baos = java.io.ByteArrayOutputStream() + + fun writeln(s: String) = baos.write(("$s\r\n").toByteArray()) + + for ((name, value) in parts) { + writeln("--$boundary") + when (value) { + is File -> { + writeln("Content-Disposition: form-data; name=\"$name\"; filename=\"${value.name}\"") + writeln("Content-Type: application/octet-stream") + writeln("") + baos.write(value.readBytes()) + writeln("") + } + else -> { + writeln("Content-Disposition: form-data; name=\"$name\"") + writeln("") + writeln(value.toString()) + } + } + } + writeln("--$boundary--") + + val body = baos.toByteArray() + val client = HttpClient.newHttpClient() + val builder = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "multipart/form-data; boundary=$boundary") + if (token.isNotBlank()) { + builder.header("Authorization", "Bearer $token") + } + val req = builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build() + return client.send(req, HttpResponse.BodyHandlers.ofString()).body() +} + +fun parseJson(json: String, key: String): String? = + Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1) + +fun parseJsonInt(json: String, key: String): Int? = + Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull() + +fun parseCsv(value: String?): List = + value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() + +fun promptLine(message: String): String? { + val console = System.console() ?: return null + print(message) + return console.readLine() +} + +fun escapeJson(value: String?): String { + if (value == null) return "" + return buildString(value.length + 8) { + value.forEach { ch -> + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } + } +} + +fun writeZipEntry(zip: ZipOutputStream, entryName: String, content: ByteArray) { + val entry = ZipEntry(entryName) + zip.putNextEntry(entry) + zip.write(content) + zip.closeEntry() +} + +fun addFileToZip(zip: ZipOutputStream, file: File, entryName: String) { + val entry = ZipEntry(entryName) + zip.putNextEntry(entry) + file.inputStream().use { input -> + input.copyTo(zip) + } + zip.closeEntry() +} + +fun addDirectoryToZip(zip: ZipOutputStream, dir: File, prefix: String) { + if (!dir.exists()) return + dir.walkTopDown() + .filter { it.isFile } + .forEach { file -> + val relative = file.relativeTo(dir).path.replace(File.separatorChar, '/') + addFileToZip(zip, file, "$prefix/$relative") + } +} + +fun packRnReleaseZip(bundleFile: File, resourceDir: File?, manifestJson: String): File { + val zipPath = Files.createTempFile("xuqm-rn-release-", ".zip").toFile() + ZipOutputStream(FileOutputStream(zipPath)).use { zip -> + writeZipEntry(zip, "rn-manifest.json", manifestJson.toByteArray(StandardCharsets.UTF_8)) + addFileToZip(zip, bundleFile, "bundle/${bundleFile.name}") + if (resourceDir != null && resourceDir.exists()) { + addDirectoryToZip(zip, resourceDir, "resources") + } + } + return zipPath +} + +fun findHighestVersion(listResp: String): Int { + return Regex("\"version\"\\s*:\\s*\"?(\\d+)\"?").findAll(listResp) + .mapNotNull { it.groupValues[1].toIntOrNull() } + .maxOrNull() ?: 0 +} + +tasks.register("xuqmRnRelease") { + group = "xuqm" + description = "Package RN bundle + resources into zip and upload to XuqmGroup Update Service" + + doLast { + val cfg = loadXuqmConfig(projectDir) + val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing") + val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing") + val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing") + val moduleId = cfg.getProperty("xuqm.moduleId") ?: throw GradleException("xuqm.moduleId missing") + val bundleFilePath = cfg.getProperty("xuqm.bundleFile") ?: throw GradleException("xuqm.bundleFile missing") + val bundleVersion = cfg.getProperty("xuqm.bundleVersion") + ?.trim() + ?.toIntOrNull() + ?: throw GradleException("xuqm.bundleVersion missing or invalid") + val resourceDirPath = cfg.getProperty("xuqm.resourceDir", "").trim() + val minCommonVersion = cfg.getProperty("xuqm.minCommonVersion", "").trim() + val packageName = cfg.getProperty("xuqm.packageName", "").trim() + val note = cfg.getProperty("xuqm.note", "").trim() + + val bundleFile = File(bundleFilePath) + if (!bundleFile.exists()) { + throw GradleException("Bundle file not found: ${bundleFile.absolutePath}") + } + val resourceDir = if (resourceDirPath.isNotBlank()) File(resourceDirPath) else null + if (resourceDir != null && !resourceDir.exists()) { + throw GradleException("Resource directory not found: ${resourceDir.absolutePath}") + } + + val listResp = httpGet("$serverUrl/api/v1/rn/list?appId=$appKey&moduleId=$moduleId&platform=HARMONY", apiToken) + val serverVersion = findHighestVersion(listResp) + println("[xuqm] Server latest bundleVersion: $serverVersion") + + var releaseVersion = bundleVersion + if (releaseVersion <= serverVersion && System.console() != null) { + println("[xuqm] Local bundleVersion is not greater than server latest. Please enter corrected release version info.") + val inputVersion = promptLine("Release bundleVersion [$bundleVersion]: ")?.trim().orEmpty() + if (inputVersion.isNotBlank()) { + releaseVersion = inputVersion.toInt() + } + } + if (releaseVersion <= serverVersion) { + throw GradleException( + "Release bundleVersion ($releaseVersion) must be greater than server ($serverVersion). " + + "Bump the local RN release metadata or override intentionally via script config." + ) + } + + val manifestJson = buildString { + append("{") + append("\"moduleId\":\"").append(escapeJson(moduleId)).append("\",") + append("\"platform\":\"HARMONY\",") + append("\"version\":\"").append(releaseVersion).append("\",") + append("\"bundleVersion\":").append(releaseVersion).append(",") + append("\"minCommonVersion\":\"").append(escapeJson(minCommonVersion)).append("\",") + append("\"packageName\":\"").append(escapeJson(packageName)).append("\",") + append("\"note\":\"").append(escapeJson(note)).append("\"") + append("}") + } + val zipFile = packRnReleaseZip(bundleFile, resourceDir, manifestJson) + + println("[xuqm] RN bundle zip: ${zipFile.absolutePath}") + println("[xuqm] bundleVersion=$releaseVersion, moduleId=$moduleId, minCommonVersion=${minCommonVersion.ifBlank { "-" }}, packageName=${packageName.ifBlank { "-" }}") + + val parts = mutableMapOf( + "appId" to appKey, + "moduleId" to moduleId, + "platform" to "HARMONY", + "version" to releaseVersion.toString(), + "bundle" to zipFile, + ) + if (minCommonVersion.isNotBlank()) parts["minCommonVersion"] = minCommonVersion + if (packageName.isNotBlank()) parts["packageName"] = packageName + if (note.isNotBlank()) parts["note"] = note + + println("[xuqm] Uploading RN release zip...") + val uploadResp = httpMultipartPost("$serverUrl/api/v1/rn/upload", apiToken, parts) + val versionId = parseJson(uploadResp, "id") + ?: throw GradleException("[xuqm] Upload failed:\n$uploadResp") + println("[xuqm] Uploaded, version ID: $versionId") + println("[xuqm] Release zip contains rn-manifest.json + bundle + resources") + } +} diff --git a/xuqm-sdk/src/main/ets/XuqmSDK.ets b/xuqm-sdk/src/main/ets/XuqmSDK.ets index dd45228..d983ced 100644 --- a/xuqm-sdk/src/main/ets/XuqmSDK.ets +++ b/xuqm-sdk/src/main/ets/XuqmSDK.ets @@ -2,6 +2,7 @@ import common from '@ohos.app.ability.common' import type { SDKConfig } from './core/Types' import { SDKContext } from './core/SDKContext' import { ImClient } from './im/ImClient' +import { UpdateSDK } from './update/UpdateSDK' export class XuqmSDK { private static _imClient: ImClient | null = null @@ -33,4 +34,8 @@ export class XuqmSDK { } return XuqmSDK._imClient } + + static get update(): UpdateSDK { + return UpdateSDK + } } diff --git a/xuqm-sdk/src/main/ets/core/SDKContext.ets b/xuqm-sdk/src/main/ets/core/SDKContext.ets index 7b2181c..26f31ef 100644 --- a/xuqm-sdk/src/main/ets/core/SDKContext.ets +++ b/xuqm-sdk/src/main/ets/core/SDKContext.ets @@ -1,7 +1,8 @@ import preferences from '@ohos.data.preferences' -import type { SDKConfig } from './Types' +import type { InstalledRnBundleInfo, SDKConfig } from './Types' const TOKEN_KEY = 'xuqm_token' +const RN_BUNDLE_PREFIX = 'xuqm_rn_bundle_' const PREF_NAME = 'xuqm_sdk_prefs' export class SDKContext { @@ -53,4 +54,37 @@ export class SDKContext { static getUserId(): string | null { return SDKContext._userId } + + private static rnBundleKey(bundleName: string): string { + return RN_BUNDLE_PREFIX + bundleName + } + + static async setRnBundleInfo(bundleName: string, info: InstalledRnBundleInfo): Promise { + if (!SDKContext._pref || !bundleName.trim()) { + return + } + const payload = JSON.stringify(info) + await SDKContext._pref.put(SDKContext.rnBundleKey(bundleName), payload) + await SDKContext._pref.flush() + } + + static async getRnBundleInfo(bundleName: string): Promise { + if (!SDKContext._pref || !bundleName.trim()) { + return null + } + const raw = await SDKContext._pref.get(SDKContext.rnBundleKey(bundleName), '') as string + if (!raw) { + return null + } + try { + return JSON.parse(raw) as InstalledRnBundleInfo + } catch { + return null + } + } + + static async getRnBundleVersion(bundleName: string): Promise { + const info = await SDKContext.getRnBundleInfo(bundleName) + return info?.bundleVersion ?? null + } } diff --git a/xuqm-sdk/src/main/ets/core/Types.ets b/xuqm-sdk/src/main/ets/core/Types.ets index 92c586e..a494377 100644 --- a/xuqm-sdk/src/main/ets/core/Types.ets +++ b/xuqm-sdk/src/main/ets/core/Types.ets @@ -155,9 +155,17 @@ export interface RnBundleInfo { md5: string forceUpdate: boolean packageName?: string + minCommonVersion?: string packageMatched?: boolean } +export interface InstalledRnBundleInfo { + bundleVersion: number + packageName?: string + minCommonVersion?: string + installedAt: number +} + export interface PushTokenInfo { vendor: string token: string diff --git a/xuqm-sdk/src/main/ets/update/UpdateSDK.ets b/xuqm-sdk/src/main/ets/update/UpdateSDK.ets index d520ed3..b3048fc 100644 --- a/xuqm-sdk/src/main/ets/update/UpdateSDK.ets +++ b/xuqm-sdk/src/main/ets/update/UpdateSDK.ets @@ -3,7 +3,7 @@ import common from '@ohos.app.ability.common' import request from '@ohos.request' import type { BusinessError } from '@ohos.base' import { HttpClient } from '../core/HttpClient' -import type { AppVersionInfo, RnBundleInfo } from '../core/Types' +import type { AppVersionInfo, InstalledRnBundleInfo, RnBundleInfo } from '../core/Types' import { SDKContext } from '../core/SDKContext' export interface AppUpdateResult { @@ -43,14 +43,15 @@ export class UpdateSDK { static async checkRnUpdate( appKey: string, bundleName: string, - currentBundleVersion: number, + currentBundleVersion?: number, packageName?: string ): Promise { + const localBundleVersion = currentBundleVersion ?? await SDKContext.getRnBundleVersion(bundleName) ?? 0 const data = await HttpClient.get( - `/api/v1/rn/update/check?appKey=${appKey}&bundleName=${bundleName}&bundleVersion=${currentBundleVersion}${packageName ? `&packageName=${encodeURIComponent(packageName)}` : ''}` + `/api/v1/rn/update/check?appKey=${appKey}&bundleName=${bundleName}&bundleVersion=${localBundleVersion}${packageName ? `&packageName=${encodeURIComponent(packageName)}` : ''}` ) - if (data.bundleVersion <= currentBundleVersion) { + if (data.bundleVersion <= localBundleVersion) { return { hasUpdate: false } } if (packageName && data.packageMatched === false) { @@ -62,7 +63,9 @@ export class UpdateSDK { static async downloadRnBundle( context: common.UIAbilityContext, downloadUrl: string, - destFilename: string + destFilename: string, + bundleName?: string, + bundleInfo?: RnBundleInfo, ): Promise { const destPath = context.cacheDir + '/' + destFilename await new Promise((resolve, reject) => { @@ -75,9 +78,36 @@ export class UpdateSDK { task.on('fail', (error: number) => reject(new Error(`Download failed: ${error}`))) }) }) + if (bundleName && bundleInfo) { + const installedInfo: InstalledRnBundleInfo = { + bundleVersion: bundleInfo.bundleVersion, + packageName: bundleInfo.packageName, + minCommonVersion: bundleInfo.minCommonVersion, + installedAt: Date.now(), + } + await SDKContext.setRnBundleInfo(bundleName, installedInfo) + } if (SDKContext.getConfig().debug) { console.log('[UpdateSDK] RN bundle downloaded to', destPath) } return destPath } + + static async rememberRnBundleInfo( + bundleName: string, + bundleVersion: number, + packageName?: string, + minCommonVersion?: string, + ): Promise { + await SDKContext.setRnBundleInfo(bundleName, { + bundleVersion, + packageName, + minCommonVersion, + installedAt: Date.now(), + }) + } + + static async getInstalledRnBundleInfo(bundleName: string): Promise { + return SDKContext.getRnBundleInfo(bundleName) + } }