/** * 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") } }