XuqmGroup-HarmonySDK/xuqm-sdk/scripts/xuqm_release.gradle.kts

217 行
11 KiB
Plaintext

/**
* XuqmGroup Update Service — HarmonyOS hvigorw Release Task
*
* HarmonyOS projects use hvigorw (Huawei's Gradle wrapper).
* This script registers a task compatible with both hvigorw and standard Gradle.
*
* Copy to your HarmonyOS module directory and apply:
* apply(from = "xuqm_release.gradle.kts") // in build.gradle.kts
*
* Run:
* ./hvigorw xuqmRelease --mode project
*
* Config: xuqm.properties in the module or project root
* ---
* xuqm.serverUrl=https://update.dev.xuqinmin.com
* xuqm.appKey=your-app-key
* xuqm.apiToken=your-api-token
* xuqm.autoPublishAfterReview=false
* xuqm.publishImmediately=false
* xuqm.scheduledPublishAt= # optional ISO datetime
* xuqm.webhookUrl= # optional
* xuqm.marketUrl= # Harmony app market URL
* ---
*
* Version is read from AppScope/app.json5 (standard HarmonyOS project layout).
*/
import org.gradle.api.GradleException
import java.io.File
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.util.Properties
import java.util.UUID
import java.util.regex.Pattern
// ── Config ────────────────────────────────────────────────────────────────
fun loadXuqmCfg(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")
return props
}
// ── Version from app.json5 ───────────────────────────────────────────────
data class HarmonyVersion(val name: String, val code: Int)
fun readHarmonyVersion(projectDir: File): HarmonyVersion {
val json5 = listOf(
File(projectDir, "AppScope/app.json5"),
File(projectDir.parentFile, "AppScope/app.json5"),
).firstOrNull { it.exists() } ?: throw GradleException("AppScope/app.json5 not found")
val content = json5.readText()
val name = Regex(""""versionName"\s*:\s*"([^"]+)"""").find(content)?.groupValues?.get(1)
?: throw GradleException("versionName not found in app.json5")
val code = Regex(""""versionCode"\s*:\s*(\d+)""").find(content)?.groupValues?.get(1)?.toInt()
?: throw GradleException("versionCode not found in app.json5")
return HarmonyVersion(name, code)
}
fun readHarmonyBundleName(projectDir: File): String {
val json5 = listOf(
File(projectDir, "AppScope/app.json5"),
File(projectDir.parentFile, "AppScope/app.json5"),
).firstOrNull { it.exists() } ?: throw GradleException("AppScope/app.json5 not found")
val content = json5.readText()
return Regex(""""bundleName"\s*:\s*"([^"]+)"""").find(content)?.groupValues?.get(1)
?: throw GradleException("bundleName not found in app.json5")
}
// ── HTTP helpers ──────────────────────────────────────────────────────────
fun httpGet(url: String, token: String): String {
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create(url)).header("Authorization", "Bearer $token").GET().build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
fun httpMultipartPost(url: String, token: String, parts: Map<String, Any>): 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 client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer $token")
.header("Content-Type", "multipart/form-data; boundary=$boundary")
.POST(HttpRequest.BodyPublishers.ofByteArray(baos.toByteArray()))
.build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
fun parseJson(json: String, key: String) =
Regex(""""$key"\s*:\s*"([^"]+)"""").find(json)?.groupValues?.get(1)
fun promptLine(message: String): String? {
val console = System.console() ?: return null
print(message)
return console.readLine()
}
fun promptYesNo(message: String, default: Boolean = false): Boolean {
val answer = promptLine(message + if (default) " [Y/n]: " else " [y/N]: ")?.trim().orEmpty()
return when {
answer.isBlank() -> default
answer.equals("y", ignoreCase = true) || answer.equals("yes", ignoreCase = true) -> true
answer.equals("n", ignoreCase = true) || answer.equals("no", ignoreCase = true) -> false
else -> default
}
}
// ── Task ──────────────────────────────────────────────────────────────────
tasks.register("xuqmRelease") {
group = "xuqm"
description = "Build HAP and upload to XuqmGroup Update Service"
// hvigorw uses 'assembleApp' by default; adjust if your task is named differently
dependsOn(tasks.findByName("assembleApp") ?: tasks.findByName("default") ?: return@register)
doLast {
val cfg = loadXuqmCfg(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 autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean()
val publishImmediately = cfg.getProperty("xuqm.publishImmediately", "false").toBoolean()
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "")
val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "")
val marketUrl = cfg.getProperty("xuqm.marketUrl", "")
var publishMode = cfg.getProperty("xuqm.publishMode", "").trim().uppercase()
if (publishMode.isBlank() && !publishImmediately && scheduledAt.isBlank() && !autoPublish && System.console() != null) {
publishMode = promptReleaseMode()
if (publishMode == "SCHEDULED" && scheduledAt.isBlank()) {
scheduledAt = promptLine("Scheduled publish time (ISO datetime): ")?.trim().orEmpty()
}
}
// ── 1. Read local version ──────────────────────────────────────────
val (versionName, versionCode) = readHarmonyVersion(projectDir)
val bundleName = readHarmonyBundleName(projectDir)
println("[xuqm] Local version: $versionName ($versionCode), appKey: $appKey")
// ── 2. Check server ────────────────────────────────────────────────
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appKey&platform=HARMONY", apiToken)
val serverCode = Regex(""""versionCode"\s*:\s*(\d+)""").findAll(listResp)
.mapNotNull { it.groupValues[1].toIntOrNull() }.maxOrNull() ?: 0
println("[xuqm] Server latest versionCode: $serverCode")
if (versionCode <= serverCode) {
throw GradleException(
"[xuqm] Local versionCode ($versionCode) ≤ server ($serverCode). " +
"Bump versionCode in AppScope/app.json5 first."
)
}
// ── 3. Locate HAP ──────────────────────────────────────────────────
// ── 4. Upload release metadata to update service ──────────────────
if (marketUrl.isBlank()) {
throw GradleException("xuqm.marketUrl missing for Harmony release")
}
val parts = mutableMapOf<String, Any>(
"appId" to appKey, "platform" to "HARMONY",
"versionName" to versionName, "versionCode" to versionCode,
"forceUpdate" to "false", "autoPublishAfterReview" to autoPublish.toString(),
"packageName" to bundleName,
"marketUrl" to marketUrl,
)
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
if (publishMode == "NOW") parts["publishImmediately"] = "true"
println("[xuqm] Uploading Harmony release metadata...")
val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
val versionId = parseJson(uploadResp, "id")
?: throw GradleException("[xuqm] Upload failed:\n$uploadResp")
println("[xuqm] Uploaded, version ID: $versionId")
if (publishImmediately || publishMode == "NOW") {
println("[xuqm] Published immediately in update service.")
} else if (publishMode == "SCHEDULED" || scheduledAt.isNotBlank()) {
println("[xuqm] Will auto-publish at: ${scheduledAt.ifBlank { "(use update service scheduled publish config)" }}")
} else if (autoPublish) {
println("[xuqm] Will auto-publish after store reviews pass.")
} else if (System.console() != null && promptYesNo("[xuqm] Publish now?", false)) {
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/publish"))
.header("Authorization", "Bearer $apiToken")
.POST(HttpRequest.BodyPublishers.noBody())
.build()
val publishResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Publish HTTP ${publishResp.statusCode()}")
}
println("[xuqm] Done.")
if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) {
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
}
}
}