2026-04-29 00:37:18 +08:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-04-29 15:46:39 +08:00
|
|
|
* xuqm.appKey=your-app-key
|
2026-04-29 00:37:18 +08:00
|
|
|
* xuqm.apiToken=your-api-token
|
|
|
|
|
* xuqm.autoPublishAfterReview=false
|
2026-04-29 15:46:39 +08:00
|
|
|
* xuqm.publishImmediately=false
|
2026-04-29 00:37:18 +08:00
|
|
|
* xuqm.scheduledPublishAt= # optional ISO datetime
|
|
|
|
|
* xuqm.webhookUrl= # optional
|
2026-04-29 15:46:39 +08:00
|
|
|
* xuqm.marketUrl= # Harmony app market URL
|
2026-04-29 00:37:18 +08:00
|
|
|
* ---
|
|
|
|
|
*
|
|
|
|
|
* 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:46:39 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 00:37:18 +08:00
|
|
|
// ── 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)
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
fun parseJsonBool(json: String, key: String, default: Boolean = false): Boolean =
|
|
|
|
|
Regex(""""$key"\s*:\s*(true|false)""").find(json)?.groupValues?.get(1)?.toBooleanStrictOrNull() ?: default
|
|
|
|
|
|
|
|
|
|
fun parseJsonInt(json: String, key: String, default: Int = 0): Int =
|
|
|
|
|
Regex(""""$key"\s*:\s*(\d+)""").find(json)?.groupValues?.get(1)?.toIntOrNull() ?: default
|
|
|
|
|
|
|
|
|
|
fun parseCsv(value: String?): List<String> =
|
|
|
|
|
value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
|
|
|
|
|
|
2026-04-29 15:46:39 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
data class ReleaseDefaults(
|
|
|
|
|
val enabled: Boolean = true,
|
|
|
|
|
val defaultStoreTargets: List<String> = emptyList(),
|
|
|
|
|
val defaultPublishMode: String = "MANUAL",
|
|
|
|
|
val defaultPublishImmediately: Boolean = false,
|
|
|
|
|
val defaultScheduledPublishAt: String = "",
|
|
|
|
|
val defaultAutoPublishAfterReview: Boolean = false,
|
|
|
|
|
val defaultWebhookUrl: String = "",
|
|
|
|
|
val defaultForceUpdate: Boolean = false,
|
|
|
|
|
val defaultGrayEnabled: Boolean = false,
|
|
|
|
|
val defaultGrayPercent: Int = 0,
|
|
|
|
|
val defaultPackageName: String = "",
|
|
|
|
|
val defaultAppStoreUrl: String = "",
|
|
|
|
|
val defaultMarketUrl: String = "",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fun fetchReleaseDefaults(tenantUrl: String, appKey: String): ReleaseDefaults? {
|
|
|
|
|
if (tenantUrl.isBlank()) return null
|
|
|
|
|
val body = runCatching {
|
|
|
|
|
val uri = URI("$tenantUrl/api/sdk/config?appId=$appKey&platform=HARMONY")
|
|
|
|
|
val req = HttpRequest.newBuilder(uri).GET().build()
|
|
|
|
|
HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body()
|
|
|
|
|
}.getOrNull() ?: return null
|
|
|
|
|
if (!parseJsonBool(body, "updateEnabled", false)) return ReleaseDefaults(enabled = false)
|
|
|
|
|
return ReleaseDefaults(
|
|
|
|
|
enabled = true,
|
|
|
|
|
defaultStoreTargets = parseCsv(parseJson(body, "updateDefaultStoreTargets")),
|
|
|
|
|
defaultPublishMode = parseJson(body, "updateDefaultPublishMode") ?: "MANUAL",
|
|
|
|
|
defaultPublishImmediately = parseJsonBool(body, "updateDefaultPublishImmediately"),
|
|
|
|
|
defaultScheduledPublishAt = parseJson(body, "updateDefaultScheduledPublishAt").orEmpty(),
|
|
|
|
|
defaultAutoPublishAfterReview = parseJsonBool(body, "updateDefaultAutoPublishAfterReview"),
|
|
|
|
|
defaultWebhookUrl = parseJson(body, "updateDefaultWebhookUrl").orEmpty(),
|
|
|
|
|
defaultForceUpdate = parseJsonBool(body, "updateDefaultForceUpdate"),
|
|
|
|
|
defaultGrayEnabled = parseJsonBool(body, "updateDefaultGrayEnabled"),
|
|
|
|
|
defaultGrayPercent = parseJsonInt(body, "updateDefaultGrayPercent"),
|
|
|
|
|
defaultPackageName = parseJson(body, "updateDefaultPackageName").orEmpty(),
|
|
|
|
|
defaultAppStoreUrl = parseJson(body, "updateDefaultAppStoreUrl").orEmpty(),
|
|
|
|
|
defaultMarketUrl = parseJson(body, "updateDefaultMarketUrl").orEmpty(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 00:37:18 +08:00
|
|
|
// ── 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")
|
2026-04-29 15:46:39 +08:00
|
|
|
val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing")
|
2026-04-29 00:37:18 +08:00
|
|
|
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
|
2026-04-29 17:35:52 +08:00
|
|
|
val tenantUrl = cfg.getProperty("xuqm.tenantUrl", "").trim()
|
|
|
|
|
val tenantDefaults = fetchReleaseDefaults(tenantUrl, appKey)
|
|
|
|
|
val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", tenantDefaults?.defaultAutoPublishAfterReview?.toString() ?: "false").toBoolean()
|
|
|
|
|
var publishImmediately = cfg.getProperty("xuqm.publishImmediately", tenantDefaults?.defaultPublishImmediately?.toString() ?: "false").toBoolean()
|
|
|
|
|
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", tenantDefaults?.defaultScheduledPublishAt ?: "")
|
|
|
|
|
var webhookUrl = cfg.getProperty("xuqm.webhookUrl", tenantDefaults?.defaultWebhookUrl ?: "")
|
|
|
|
|
var marketUrl = cfg.getProperty("xuqm.marketUrl", tenantDefaults?.defaultMarketUrl ?: "")
|
|
|
|
|
var publishMode = cfg.getProperty("xuqm.publishMode", tenantDefaults?.defaultPublishMode ?: "").trim().uppercase()
|
|
|
|
|
var dryRun = cfg.getProperty("xuqm.dryRun", "false").toBoolean() || publishMode == "DRY_RUN" || tenantDefaults?.enabled == false
|
|
|
|
|
val allowVersionMismatch = cfg.getProperty("xuqm.allowVersionMismatch", "false").toBoolean()
|
2026-04-29 15:46:39 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-29 17:35:52 +08:00
|
|
|
if (publishMode == "DRY_RUN") {
|
|
|
|
|
dryRun = true
|
|
|
|
|
println("[xuqm] Dry-run mode enabled.")
|
|
|
|
|
}
|
|
|
|
|
if (!scheduledAt.isBlank()) {
|
|
|
|
|
publishImmediately = false
|
|
|
|
|
}
|
|
|
|
|
if (publishMode == "NOW") {
|
|
|
|
|
publishImmediately = true
|
|
|
|
|
autoPublish = false
|
|
|
|
|
} else if (publishMode == "AUTO_REVIEW") {
|
|
|
|
|
autoPublish = true
|
|
|
|
|
publishImmediately = false
|
|
|
|
|
}
|
2026-04-29 00:37:18 +08:00
|
|
|
|
|
|
|
|
// ── 1. Read local version ──────────────────────────────────────────
|
2026-04-29 17:35:52 +08:00
|
|
|
var (versionName, versionCode) = readHarmonyVersion(projectDir)
|
2026-04-29 15:46:39 +08:00
|
|
|
val bundleName = readHarmonyBundleName(projectDir)
|
|
|
|
|
println("[xuqm] Local version: $versionName ($versionCode), appKey: $appKey")
|
2026-04-29 00:37:18 +08:00
|
|
|
|
|
|
|
|
// ── 2. Check server ────────────────────────────────────────────────
|
2026-04-29 15:46:39 +08:00
|
|
|
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appKey&platform=HARMONY", apiToken)
|
2026-04-29 00:37:18 +08:00
|
|
|
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) {
|
2026-04-29 17:35:52 +08:00
|
|
|
if (!allowVersionMismatch && System.console() != null) {
|
|
|
|
|
println("[xuqm] Local version is not greater than server latest. Please enter corrected release version info.")
|
|
|
|
|
val inputVersionName = promptLine("Release versionName [$versionName]: ")?.trim().orEmpty()
|
|
|
|
|
val inputVersionCode = promptLine("Release versionCode [$versionCode]: ")?.trim().orEmpty()
|
|
|
|
|
if (inputVersionName.isNotBlank()) versionName = inputVersionName
|
|
|
|
|
if (inputVersionCode.isNotBlank()) versionCode = inputVersionCode.toInt()
|
|
|
|
|
}
|
|
|
|
|
if (versionCode <= serverCode) {
|
|
|
|
|
throw GradleException(
|
|
|
|
|
"[xuqm] Release versionCode ($versionCode) must be greater than server ($serverCode). " +
|
|
|
|
|
"Bump versionCode in AppScope/app.json5 or pass xuqm.allowVersionMismatch=true if you deliberately override."
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-29 00:37:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 3. Locate HAP ──────────────────────────────────────────────────
|
2026-04-29 15:46:39 +08:00
|
|
|
// ── 4. Upload release metadata to update service ──────────────────
|
|
|
|
|
if (marketUrl.isBlank()) {
|
|
|
|
|
throw GradleException("xuqm.marketUrl missing for Harmony release")
|
|
|
|
|
}
|
2026-04-29 17:35:52 +08:00
|
|
|
if (dryRun) {
|
|
|
|
|
println("[xuqm] Dry-run summary:")
|
|
|
|
|
println(" updateEnabled=${tenantDefaults?.enabled ?: true}")
|
|
|
|
|
println(" releaseVersion=$versionName ($versionCode)")
|
|
|
|
|
println(" publishMode=${publishMode.ifBlank { "MANUAL" }}")
|
|
|
|
|
println(" publishImmediately=$publishImmediately")
|
|
|
|
|
println(" autoPublishAfterReview=$autoPublish")
|
|
|
|
|
println(" scheduledAt=${scheduledAt.ifBlank { "-" }}")
|
|
|
|
|
println(" webhookUrl=${webhookUrl.ifBlank { "-" }}")
|
|
|
|
|
println(" marketUrl=${marketUrl.ifBlank { "-" }}")
|
|
|
|
|
println("[xuqm] Dry-run completed. No upload performed.")
|
|
|
|
|
return@doLast
|
|
|
|
|
}
|
2026-04-29 00:37:18 +08:00
|
|
|
val parts = mutableMapOf<String, Any>(
|
2026-04-29 15:46:39 +08:00
|
|
|
"appId" to appKey, "platform" to "HARMONY",
|
2026-04-29 00:37:18 +08:00
|
|
|
"versionName" to versionName, "versionCode" to versionCode,
|
|
|
|
|
"forceUpdate" to "false", "autoPublishAfterReview" to autoPublish.toString(),
|
2026-04-29 15:46:39 +08:00
|
|
|
"packageName" to bundleName,
|
|
|
|
|
"marketUrl" to marketUrl,
|
2026-04-29 00:37:18 +08:00
|
|
|
)
|
|
|
|
|
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
|
|
|
|
|
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
|
2026-04-29 15:46:39 +08:00
|
|
|
if (publishMode == "NOW") parts["publishImmediately"] = "true"
|
2026-04-29 00:37:18 +08:00
|
|
|
|
2026-04-29 15:46:39 +08:00
|
|
|
println("[xuqm] Uploading Harmony release metadata...")
|
2026-04-29 00:37:18 +08:00
|
|
|
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")
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
if (publishMode == "NOW") publishImmediately = true
|
2026-04-29 15:46:39 +08:00
|
|
|
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)) {
|
2026-04-29 00:37:18 +08:00
|
|
|
val client = HttpClient.newHttpClient()
|
2026-04-29 15:46:39 +08:00
|
|
|
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/publish"))
|
2026-04-29 00:37:18 +08:00
|
|
|
.header("Authorization", "Bearer $apiToken")
|
2026-04-29 15:46:39 +08:00
|
|
|
.POST(HttpRequest.BodyPublishers.noBody())
|
|
|
|
|
.build()
|
|
|
|
|
val publishResp = client.send(req, HttpResponse.BodyHandlers.ofString())
|
|
|
|
|
println("[xuqm] Publish HTTP ${publishResp.statusCode()}")
|
2026-04-29 00:37:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println("[xuqm] Done.")
|
2026-04-29 15:46:39 +08:00
|
|
|
if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) {
|
2026-04-29 00:37:18 +08:00
|
|
|
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|