Просмотр исходного кода

修复检测更新时遇到403报错的问题

a328929 3 месяцев назад
Родитель
Сommit
0f1bf545cf

+ 117 - 0
.github/scripts/publish-update-manifest.sh

@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+channel="${1:-}"
+version="${2:-}"
+
+if [[ "$channel" != "official" && "$channel" != "beta" ]]; then
+  echo "Unsupported update channel: $channel" >&2
+  exit 1
+fi
+if [[ -z "$version" ]]; then
+  echo "Release version is required" >&2
+  exit 1
+fi
+: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
+: "${RUNNER_TEMP:?RUNNER_TEMP is required}"
+
+manifest_branch="update-manifests"
+manifest_dir="$RUNNER_TEMP/$manifest_branch"
+release_tmp="$manifest_dir/release.tmp"
+release_list_tmp="$manifest_dir/releases.tmp"
+
+write_manifest() {
+  local source_file="$1"
+  local target_channel="$2"
+  local expected_version="$3"
+  local target_file="$manifest_dir/$target_channel.json"
+  local target_tmp="$target_file.tmp"
+  local expected_prerelease="false"
+  if [[ "$target_channel" == "beta" ]]; then
+    expected_prerelease="true"
+  fi
+
+  jq -e \
+    --arg version "$expected_version" \
+    --argjson prerelease "$expected_prerelease" \
+    'if .tag_name == $version and .prerelease == $prerelease and (.assets | type == "array") then
+      {
+        tag_name,
+        name,
+        body: (.body // ""),
+        prerelease,
+        created_at,
+        assets: [.assets[] | {
+          browser_download_url,
+          content_type,
+          created_at,
+          download_count: 0,
+          id,
+          name,
+          state,
+          url
+        }]
+      }
+    else
+      error("Release response does not match the requested channel and version")
+    end' \
+    "$source_file" > "$target_tmp"
+  mv "$target_tmp" "$target_file"
+}
+
+rm -rf "$manifest_dir"
+if git fetch --no-tags origin "$manifest_branch"; then
+  git worktree add --detach "$manifest_dir" FETCH_HEAD
+else
+  git worktree add --detach "$manifest_dir" HEAD
+  git -C "$manifest_dir" checkout --orphan "$manifest_branch"
+  git -C "$manifest_dir" rm -q -rf --ignore-unmatch .
+fi
+trap 'git worktree remove --force "$manifest_dir" >/dev/null 2>&1 || true' EXIT
+
+gh api \
+  -H "Accept: application/vnd.github+json" \
+  -H "X-GitHub-Api-Version: 2026-03-10" \
+  "repos/$GITHUB_REPOSITORY/releases/tags/$version" > "$release_tmp"
+write_manifest "$release_tmp" "$channel" "$version"
+
+other_channel="official"
+if [[ "$channel" == "official" ]]; then
+  other_channel="beta"
+fi
+
+if [[ ! -f "$manifest_dir/$other_channel.json" ]]; then
+  if [[ "$other_channel" == "official" ]]; then
+    gh api \
+      -H "Accept: application/vnd.github+json" \
+      -H "X-GitHub-Api-Version: 2026-03-10" \
+      "repos/$GITHUB_REPOSITORY/releases/latest" > "$release_tmp"
+    other_version="$(jq -r '.tag_name' "$release_tmp")"
+    write_manifest "$release_tmp" "$other_channel" "$other_version"
+  else
+    gh api \
+      -H "Accept: application/vnd.github+json" \
+      -H "X-GitHub-Api-Version: 2026-03-10" \
+      "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$release_list_tmp"
+    if jq -e \
+      '[.[] | select(.draft == false and .prerelease == true)] | max_by(.created_at) | select(. != null)' \
+      "$release_list_tmp" > "$release_tmp"; then
+      other_version="$(jq -r '.tag_name' "$release_tmp")"
+      write_manifest "$release_tmp" "$other_channel" "$other_version"
+    fi
+  fi
+fi
+rm -f "$release_tmp" "$release_list_tmp"
+
+git -C "$manifest_dir" config user.name "github-actions[bot]"
+git -C "$manifest_dir" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+git -C "$manifest_dir" add --all
+
+if git -C "$manifest_dir" diff --cached --quiet; then
+  echo "$channel update manifest is already current"
+  exit 0
+fi
+
+git -C "$manifest_dir" commit -m "Update release manifests for $version"
+git -C "$manifest_dir" push origin "HEAD:refs/heads/$manifest_branch"

+ 13 - 0
.github/workflows/auto-release.yml

@@ -383,6 +383,19 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
+      - name: 发布静态更新清单
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          VERSION: ${{ needs.prepare.outputs.versionL }}
+          IS_RELEASE: ${{ needs.prepare.outputs.is_release }}
+        run: |
+          if [[ "$IS_RELEASE" == "true" ]]; then
+            CHANNEL="official"
+          else
+            CHANNEL="beta"
+          fi
+          bash .github/scripts/publish-update-manifest.sh "$CHANNEL" "$VERSION"
+
       - name: 发布到 Telegram 频道
         env:
           TG_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}

+ 9 - 9
app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt

@@ -27,7 +27,7 @@ enum class AppVariant {
 @Keep
 data class GithubRelease(
     val assets: List<Asset>?,
-    val body: String,
+    val body: String?,
     @SerializedName("prerelease")
     val isPreRelease: Boolean,
     @SerializedName("tag_name")
@@ -43,14 +43,15 @@ data class GithubRelease(
 
         val version = tagName
         return selectCompatibleAssets(assets, supportedAbis)
-            .map { it.assetToAppReleaseInfo(isPreRelease, body, version) }
+            .map { it.assetToAppReleaseInfo(isPreRelease, body.orEmpty(), version) }
     }
 }
 
-private val releaseAbiSuffixes = listOf(
+private val releaseAbis = listOf(
     "arm64-v8a",
     "armeabi-v7a",
     "x86_64",
+    "armeabi",
     "x86"
 )
 
@@ -62,18 +63,17 @@ internal fun selectCompatibleAssets(
 
     supportedAbis.forEach { supportedAbi ->
         val matchingAssets = validAssets.filter { asset ->
-            asset.name.contains(supportedAbi, ignoreCase = true)
+            asset.releaseAbi.equals(supportedAbi, ignoreCase = true)
         }
         if (matchingAssets.isNotEmpty()) return matchingAssets
     }
 
-    return validAssets.filter { asset ->
-        releaseAbiSuffixes.none { abiSuffix ->
-            asset.name.contains(abiSuffix, ignoreCase = true)
-        }
-    }
+    return validAssets.filter { it.releaseAbi == null }
 }
 
+private val Asset.releaseAbi: String?
+    get() = releaseAbis.firstOrNull { name.contains(it, ignoreCase = true) }
+
 @Keep
 data class Asset(
     @SerializedName("browser_download_url")

+ 177 - 41
app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt

@@ -10,10 +10,23 @@ import io.legado.app.help.http.text
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.withTimeoutOrNull
+import okhttp3.Headers
+import okhttp3.Request
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
 
 object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
 
+    private const val repoPath = "HapeLee/legado-with-MD3"
+    private const val githubApiBaseUrl = "https://api.github.com/repos/$repoPath/releases"
+    private const val updateManifestBaseUrl =
+        "https://raw.githubusercontent.com/$repoPath/update-manifests"
+    private const val manifestTimeoutMillis = 2500L
+
     private val checkVariant: AppVariant
         get() = when (AppConfig.updateToVariant) {
             "official_version" -> AppVariant.OFFICIAL
@@ -22,55 +35,96 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
             else -> AppConst.appInfo.appVariant
         }
 
-    private suspend fun getLatestRelease(): List<AppReleaseInfo> {
-        val url = if (checkVariant == AppVariant.OFFICIAL)
-            "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest"
-        else
-            "https://api.github.com/repos/HapeLee/legado-with-MD3/releases"
-
-        val res = okHttpClient.newCallResponse { url(url) }
-        if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
-
-        val body = res.body.text()
-        if (body.isBlank()) throw NoStackTraceException("获取新版本出错")
+    private suspend fun getLatestRelease(variant: AppVariant): List<AppReleaseInfo> {
+        val releases = getManifestReleases(variant) ?: getApiReleases(variant)
+        return releases
+            .flatMap { it.gitReleaseToAppReleaseInfo() }
+            .sortedByDescending { it.createdAt }
+    }
 
-        return when (checkVariant) {
-            AppVariant.BETA_RELEASE -> {
-                val releases = GSON.fromJsonArray<GithubRelease>(body)
-                    .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
+    private suspend fun getManifestReleases(variant: AppVariant): List<GithubRelease>? {
+        val releases = when (variant) {
+            AppVariant.OFFICIAL -> listOfNotNull(getManifestRelease(AppVariant.OFFICIAL))
+            AppVariant.BETA_RELEASE -> listOfNotNull(getManifestRelease(AppVariant.BETA_RELEASE))
+            AppVariant.ALL -> listOfNotNull(
+                getManifestRelease(AppVariant.OFFICIAL),
+                getManifestRelease(AppVariant.BETA_RELEASE)
+            )
+            else -> emptyList()
+        }
+        val expectedCount = if (variant == AppVariant.ALL) 2 else 1
+        return releases.takeIf { it.size == expectedCount }
+    }
 
-                releases.filter { it.isPreRelease }
-                    .flatMap { it.gitReleaseToAppReleaseInfo() }
-                    .sortedByDescending { it.createdAt }
+    private suspend fun getManifestRelease(variant: AppVariant): GithubRelease? {
+        val channel = when (variant) {
+            AppVariant.OFFICIAL -> "official"
+            AppVariant.BETA_RELEASE -> "beta"
+            else -> return null
+        }
+        return try {
+            withTimeoutOrNull(manifestTimeoutMillis) {
+                val response = okHttpClient.newCallResponse {
+                    url("$updateManifestBaseUrl/$channel.json")
+                }
+                response.use {
+                    if (!it.isSuccessful) return@withTimeoutOrNull null
+                    val release = GSON.fromJsonObject<GithubRelease>(it.body.text()).getOrNull()
+                        ?: return@withTimeoutOrNull null
+                    val expectedPreRelease = variant == AppVariant.BETA_RELEASE
+                    release.takeIf {
+                        it.isPreRelease == expectedPreRelease &&
+                            it.assets.orEmpty().any { asset -> asset.isValid }
+                    }
+                }
             }
+        } catch (error: CancellationException) {
+            throw error
+        } catch (_: Exception) {
+            null
+        }
+    }
 
-            AppVariant.OFFICIAL -> {
-                val release = GSON.fromJsonObject<GithubRelease>(body)
-                    .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
-
-                release.gitReleaseToAppReleaseInfo()
-                    .sortedByDescending { it.createdAt }
+    private suspend fun getApiReleases(variant: AppVariant): List<GithubRelease> {
+        if (variant == AppVariant.ALL) {
+            val releases = parseReleaseList(getGithubApiBody(githubApiBaseUrl))
+            val latestOfficial = try {
+                GSON.fromJsonObject<GithubRelease>(
+                    getGithubApiBody("$githubApiBaseUrl/latest")
+                ).getOrThrow()
+            } catch (error: CancellationException) {
+                throw error
+            } catch (_: Exception) {
+                null
             }
+            return (listOfNotNull(latestOfficial) + releases).distinctBy { it.tagName }
+        }
 
-            AppVariant.ALL -> {
-                val releases = GSON.fromJsonArray<GithubRelease>(body)
+        val url = if (variant == AppVariant.OFFICIAL) {
+            "$githubApiBaseUrl/latest"
+        } else {
+            githubApiBaseUrl
+        }
+        val body = getGithubApiBody(url)
+        return when (variant) {
+            AppVariant.OFFICIAL -> listOf(
+                GSON.fromJsonObject<GithubRelease>(body)
                     .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
-
-                releases.flatMap { it.gitReleaseToAppReleaseInfo() }
-                    .sortedByDescending { it.createdAt }
-            }
-
+            )
+            AppVariant.BETA_RELEASE -> parseReleaseList(body)
+                .filter { it.isPreRelease }
             else -> emptyList()
         }
     }
 
-    suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
-        val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag"
-        val res = okHttpClient.newCallResponse { url(url) }
-        if (!res.isSuccessful) return null
+    private fun parseReleaseList(body: String): List<GithubRelease> {
+        return GSON.fromJsonArray<GithubRelease>(body)
+            .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
+    }
 
-        val body = res.body.text()
-        val release = GSON.fromJsonObject<GithubRelease>(body).getOrElse { return null }
+    suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
+        val manifestRelease = findManifestRelease(tag)
+        val release = manifestRelease ?: getApiReleaseByTag(tag) ?: return null
         val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null
 
         return AppUpdate.UpdateInfo(
@@ -81,15 +135,61 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
         )
     }
 
+    private suspend fun findManifestRelease(tag: String): GithubRelease? {
+        val variants = if (tag.contains("beta", ignoreCase = true)) {
+            listOf(AppVariant.BETA_RELEASE, AppVariant.OFFICIAL)
+        } else {
+            listOf(AppVariant.OFFICIAL, AppVariant.BETA_RELEASE)
+        }
+        variants.forEach { variant ->
+            getManifestRelease(variant)?.let { release ->
+                if (release.tagName == tag) return release
+            }
+        }
+        return null
+    }
+
+    private suspend fun getApiReleaseByTag(tag: String): GithubRelease? {
+        val response = okHttpClient.newCallResponse {
+            url("$githubApiBaseUrl/tags/$tag")
+            addGithubApiHeaders()
+        }
+        response.use {
+            if (it.code == 404) return null
+            val body = it.body.text()
+            if (!it.isSuccessful) throw githubApiException(it.code, it.headers, body)
+            return GSON.fromJsonObject<GithubRelease>(body).getOrNull()
+        }
+    }
+
+    private suspend fun getGithubApiBody(url: String): String {
+        val response = okHttpClient.newCallResponse {
+            url(url)
+            addGithubApiHeaders()
+        }
+        response.use {
+            val body = it.body.text()
+            if (!it.isSuccessful) throw githubApiException(it.code, it.headers, body)
+            if (body.isBlank()) throw NoStackTraceException("获取新版本出错")
+            return body
+        }
+    }
+
+    private fun Request.Builder.addGithubApiHeaders() {
+        header("Accept", "application/vnd.github+json")
+        header("X-GitHub-Api-Version", "2026-03-10")
+    }
+
     override fun check(scope: CoroutineScope): Coroutine<AppUpdate.UpdateInfo> {
         return Coroutine.async(scope) {
             val currentVersion = AppConst.appInfo.versionName
-            val releases = getLatestRelease()
+            val variant = checkVariant
+            val releases = getLatestRelease(variant)
 
-            val filtered = if (checkVariant == AppVariant.ALL) {
+            val filtered = if (variant == AppVariant.ALL) {
                 releases
             } else {
-                releases.filter { it.appVariant == checkVariant }
+                releases.filter { it.appVariant == variant }
             }
 
             val latest = filtered.firstOrNull { r ->
@@ -165,4 +265,40 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
     fun String.versionCompare(other: String): Int {
         return SemVer.parse(this).compareTo(SemVer.parse(other))
     }
-}
+}
+
+internal fun githubApiException(
+    code: Int,
+    headers: Headers,
+    body: String,
+    zoneId: ZoneId = ZoneId.systemDefault()
+): NoStackTraceException {
+    if (code == 403 || code == 429) {
+        val retryAfter = headers["Retry-After"]?.toLongOrNull()
+        if (retryAfter != null) {
+            return NoStackTraceException("GitHub API 请求受限,请在 $retryAfter 秒后重试")
+        }
+        if (headers["X-RateLimit-Remaining"] == "0") {
+            val resetAt = formatRateLimitReset(headers["X-RateLimit-Reset"], zoneId)
+            val message = resetAt?.let { "GitHub API 请求额度已用完,请在 $it 后重试" }
+                ?: "GitHub API 请求额度已用完,请稍后重试"
+            return NoStackTraceException(message)
+        }
+        return NoStackTraceException("GitHub API 暂时限制请求,请稍后重试")
+    }
+
+    val detail = GSON.fromJsonObject<Map<String, String>>(body)
+        .getOrNull()
+        ?.get("message")
+        ?.takeIf { it.isNotBlank() }
+    return NoStackTraceException(
+        detail?.let { "获取新版本出错($code): $it" }
+            ?: "获取新版本出错($code)"
+    )
+}
+
+private fun formatRateLimitReset(value: String?, zoneId: ZoneId): String? {
+    val epochSeconds = value?.toLongOrNull() ?: return null
+    return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
+        .format(Instant.ofEpochSecond(epochSeconds).atZone(zoneId))
+}

+ 20 - 0
app/src/test/java/io/legado/app/help/update/AppReleaseInfoTest.kt

@@ -41,6 +41,26 @@ class AppReleaseInfoTest {
         assertEquals(emptyList<Asset>(), selected)
     }
 
+    @Test
+    fun `x86 does not match x86_64 apk`() {
+        val selected = selectCompatibleAssets(
+            assets = listOf(asset("legado-1.0.0-x86_64.apk")),
+            supportedAbis = listOf("x86")
+        )
+
+        assertEquals(emptyList<Asset>(), selected)
+    }
+
+    @Test
+    fun `armeabi does not match armeabi-v7a apk`() {
+        val selected = selectCompatibleAssets(
+            assets = listOf(asset("legado-1.0.0-armeabi-v7a.apk")),
+            supportedAbis = listOf("armeabi")
+        )
+
+        assertEquals(emptyList<Asset>(), selected)
+    }
+
     @Test
     fun `prefers the highest priority supported abi`() {
         val selected = selectCompatibleAssets(

+ 57 - 0
app/src/test/java/io/legado/app/help/update/AppUpdateGitHubTest.kt

@@ -0,0 +1,57 @@
+package io.legado.app.help.update
+
+import okhttp3.Headers
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.time.ZoneOffset
+
+class AppUpdateGitHubTest {
+
+    @Test
+    fun `primary rate limit reports reset time`() {
+        val error = githubApiException(
+            code = 403,
+            headers = headers(
+                "X-RateLimit-Remaining", "0",
+                "X-RateLimit-Reset", "1783936049"
+            ),
+            body = "",
+            zoneId = ZoneOffset.UTC
+        )
+
+        assertEquals(
+            "GitHub API 请求额度已用完,请在 2026-07-13 09:47 后重试",
+            error.message
+        )
+    }
+
+    @Test
+    fun `secondary rate limit honors retry after`() {
+        val error = githubApiException(
+            code = 429,
+            headers = headers("Retry-After", "90"),
+            body = ""
+        )
+
+        assertEquals("GitHub API 请求受限,请在 90 秒后重试", error.message)
+    }
+
+    @Test
+    fun `other api errors preserve github message`() {
+        val error = githubApiException(
+            code = 500,
+            headers = headers(),
+            body = """{"message":"Internal Server Error"}"""
+        )
+
+        assertEquals("获取新版本出错(500): Internal Server Error", error.message)
+    }
+
+    private fun headers(vararg namesAndValues: String): Headers {
+        return Headers.Builder().apply {
+            namesAndValues.asList().chunked(2).forEach { (name, value) ->
+                add(name, value)
+            }
+        }.build()
+    }
+}