feat: expose ota version badges

This commit is contained in:
DIYgod 2026-04-13 09:43:21 +08:00
parent 32c6ec3217
commit ca3fa9ffc3
7 changed files with 336 additions and 6 deletions

View File

@ -17,10 +17,12 @@
<a href="https://x.com/intent/follow?screen_name=folo_is"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
<a href="https://discord.gg/AwWcAQ7euc" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square"/></a>
<br />
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/itunes/v/6739802604?style=flat-square&logo=apple&label=App%20Store&color=FF5C00&labelColor=black" /></a>
<a href="https://play.google.com/store/apps/details?id=is.follow" target="_blank"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dis.follow%26gl%3DUS%26hl%3Den%26l%3DAndroid%26m%3D%24version&style=flat-square&logo=google-play&label=Google%20Play&labelColor=black&color=FF5C00"/></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.folo.is%2Fupdates%2Fdistribution%2Fmas&query=data.storeVersion&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600" /></a>
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.folo.is%2Fupdates%2Fdistribution%2Fmss&query=data.storeVersion&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600&prefix=v" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.store.mobile.ios.version&prefix=v&style=flat-square&logo=apple&label=App%20Store&color=FF5C00&labelColor=black&cacheSeconds=600" /></a>
<a href="https://play.google.com/store/apps/details?id=is.follow" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.store.mobile.android.version&prefix=v&style=flat-square&logo=google-play&label=Google%20Play&labelColor=black&color=FF5C00&cacheSeconds=600"/></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.store.desktop.mas.version&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.store.desktop.mss.version&prefix=v&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.github.mobile.version&prefix=v&style=flat-square&logo=github&label=GitHub%20Mobile&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.github.desktop.version&prefix=v&style=flat-square&logo=github&label=GitHub%20Desktop&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<br />
<br />
<!-- <a href="https://github.com/RSSNext/Folo" target="_blank"><img src="https://github.com/user-attachments/assets/59b957fb-59ed-4ef0-994e-f6a402a6fe2b" alt="GitHub Trending" height="55"/></a>

View File

@ -1541,7 +1541,7 @@ describe("syncStoreVersions", () => {
}
if (url === "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx") {
const body = String(init?.body ?? "")
const body = String(_init?.body ?? "")
if (body.includes("GetCookie")) {
return new Response("<EncryptedData>cookie-value</EncryptedData>", {
@ -1782,6 +1782,207 @@ describe("internal routes", () => {
})
})
describe("/versions", () => {
it("returns cached store versions and latest product release versions", async () => {
const response = await fetchWorker("/versions", undefined, {
kvEntries: new Map([
[
KV_KEYS.storeVersion("mobile", "ios"),
{
version: "0.4.1",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "app-store",
},
],
[
KV_KEYS.storeVersion("mobile", "android"),
{
version: "0.4.1",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "google-play",
},
],
[
KV_KEYS.storeVersion("desktop", "mas"),
{
version: "1.5.0",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "mac-app-store",
},
],
[
KV_KEYS.storeVersion("desktop", "mss"),
{
version: "1.5.0",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "microsoft-store",
},
],
[
KV_KEYS.latestReleaseVersion("mobile"),
{
product: "mobile",
version: "0.4.1",
publishedAt: "2026-04-10T12:00:00Z",
tag: "mobile/v0.4.1",
},
],
[
KV_KEYS.latestReleaseVersion("desktop"),
{
product: "desktop",
version: "1.5.0",
publishedAt: "2026-04-11T10:00:00Z",
tag: "desktop/v1.5.0",
},
],
]),
envOverrides: {
GITHUB_OWNER: "RSSNext",
GITHUB_REPO: "Folo",
},
})
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
store: {
mobile: {
ios: {
version: "0.4.1",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "app-store",
url: "https://apps.apple.com/us/app/folo-follow-everything/id6739802604",
},
android: {
version: "0.4.1",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "google-play",
url: "https://play.google.com/store/apps/details?id=is.follow",
},
},
desktop: {
mas: {
version: "1.5.0",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "mac-app-store",
url: "https://apps.apple.com/us/app/folo-follow-everything/id6739802604?platform=mac",
},
mss: {
version: "1.5.0",
fetchedAt: "2026-04-12T14:25:27.016Z",
source: "microsoft-store",
url: "https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct",
},
},
},
github: {
mobile: {
version: "0.4.1",
publishedAt: "2026-04-10T12:00:00Z",
tag: "mobile/v0.4.1",
url: "https://github.com/RSSNext/Folo/releases/tag/mobile/v0.4.1",
},
desktop: {
version: "1.5.0",
publishedAt: "2026-04-11T10:00:00Z",
tag: "desktop/v1.5.0",
url: "https://github.com/RSSNext/Folo/releases/tag/desktop/v1.5.0",
},
},
})
})
})
describe("latest release summary", () => {
it("persists the latest release version for each product during github sync", async () => {
const kvEntries = new Map<string, unknown>()
const mobileRelease = await createReleaseMetadata({
releaseVersion: "0.4.3",
releaseKind: "store",
runtimeVersion: "0.4.3",
publishedAt: "2026-04-10T16:00:00Z",
git: {
tag: "mobile/v0.4.3",
commit: "abcdef1234567895",
},
platforms: {},
})
const desktopRelease = createDesktopReleaseMetadata({
releaseVersion: "1.5.2",
releaseKind: "binary",
runtimeVersion: null,
publishedAt: "2026-04-11T12:00:00Z",
git: {
tag: "desktop/v1.5.2",
commit: "abcdef1234567891",
},
desktop: {
renderer: null,
app: null,
},
})
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
const url = String(input)
if (url === "https://api.github.com/repos/RSSNext/Folo/releases") {
return new Response(
JSON.stringify([
createGitHubReleaseAssetSet(
"desktop/v1.5.2",
"https://example.com/desktop.json",
null,
),
createGitHubReleaseAssetSet("mobile/v0.4.3", "https://example.com/mobile.json", null),
]),
{ status: 200 },
)
}
if (url === "https://example.com/mobile.json") {
return new Response(JSON.stringify(mobileRelease), {
headers: { "Content-Type": "application/json" },
})
}
if (url === "https://example.com/desktop.json") {
return new Response(JSON.stringify(desktopRelease), {
headers: { "Content-Type": "application/json" },
})
}
throw new Error(`Unhandled fetch URL: ${url}`)
}),
)
await syncGitHubReleases(
createEnv({
kvEntries,
envOverrides: {
GITHUB_OWNER: "RSSNext",
GITHUB_REPO: "Folo",
GITHUB_TOKEN: "token",
},
}),
)
expect(JSON.parse(String(kvEntries.get(KV_KEYS.latestReleaseVersion("mobile"))))).toEqual({
product: "mobile",
version: "0.4.3",
publishedAt: "2026-04-10T16:00:00Z",
tag: "mobile/v0.4.3",
})
expect(JSON.parse(String(kvEntries.get(KV_KEYS.latestReleaseVersion("desktop"))))).toEqual({
product: "desktop",
version: "1.5.2",
publishedAt: "2026-04-11T12:00:00Z",
tag: "desktop/v1.5.2",
})
})
})
describe("scheduled handler", () => {
it("dispatches sync work through waitUntil", async () => {
const kvEntries = new Map<string, unknown>([[KV_KEYS.githubEtag, '"etag-current"']])

View File

@ -6,12 +6,14 @@ import { assetsRoute } from "./routes/assets"
import { internalRoute } from "./routes/internal"
import { manifestRoute } from "./routes/manifest"
import { policyRoute } from "./routes/policy"
import { versionsRoute } from "./routes/versions"
export const app = new Hono<{ Bindings: Env }>()
app.route("/", manifestRoute)
app.route("/", assetsRoute)
app.route("/", policyRoute)
app.route("/", versionsRoute)
app.route("/", internalRoute)
export default {

View File

@ -17,6 +17,7 @@ export const KV_KEYS = {
distribution ? `policy:${product}:${channel}:${distribution}` : `policy:${product}:${channel}`,
storeVersion: (product: OtaRelease["product"], target: "ios" | "android" | DesktopDistribution) =>
`store-version:${product}:${target}`,
latestReleaseVersion: (product: OtaRelease["product"]) => `latest-release:${product}`,
githubEtag: "github:etag:releases",
syncLastSuccessAt: "sync:last-success-at",
storeVersionSyncLastSuccessAt: "sync:store-version-last-success-at",

View File

@ -24,6 +24,13 @@ export interface StoreVersionRecord {
source: "app-store" | "google-play" | "mac-app-store" | "microsoft-store"
}
export interface LatestReleaseVersionRecord {
product: OtaRelease["product"]
version: OtaRelease["releaseVersion"]
publishedAt: string
tag: string
}
export async function getLatestReleasePointer(
kv: KVNamespace,
input: {
@ -97,3 +104,17 @@ export async function putStoreVersionRecord(
) {
await kv.put(KV_KEYS.storeVersion(input.product, input.target), JSON.stringify(input.value))
}
export async function getLatestReleaseVersionRecord(
kv: KVNamespace,
product: OtaRelease["product"],
) {
return kv.get<LatestReleaseVersionRecord>(KV_KEYS.latestReleaseVersion(product), "json")
}
export async function putLatestReleaseVersionRecord(
kv: KVNamespace,
value: LatestReleaseVersionRecord,
) {
await kv.put(KV_KEYS.latestReleaseVersion(value.product), JSON.stringify(value))
}

View File

@ -4,7 +4,12 @@ import { buildMirroredAssetKey, extractMirroredFiles } from "./archive"
import { KV_KEYS } from "./constants"
import { listPublishedOtaReleases } from "./github"
import type { BinaryPolicyRecord, LatestReleasePointerRecord } from "./kv"
import { putBinaryPolicyRecord, putReleaseRecord, putStoreVersionRecord } from "./kv"
import {
putBinaryPolicyRecord,
putLatestReleaseVersionRecord,
putReleaseRecord,
putStoreVersionRecord,
} from "./kv"
import { putMirroredFiles } from "./r2"
import type { DesktopDistribution, OtaPlatform, OtaProjectedPlatforms, OtaRelease } from "./schema"
import { otaReleaseSchema } from "./schema"
@ -49,6 +54,10 @@ export async function syncStoreVersions(env: Env) {
}
async function runSyncGitHubReleases(env: Env) {
const latestReleaseByProduct = new Map<
OtaRelease["product"],
Pick<OtaRelease, "releaseVersion" | "publishedAt"> & { tag: string }
>()
const storedEtag = await env.OTA_KV.get<string>(KV_KEYS.githubEtag)
const releasesResult = await listPublishedOtaReleases({
owner: env.GITHUB_OWNER,
@ -64,6 +73,7 @@ async function runSyncGitHubReleases(env: Env) {
for (const releaseSummary of releasesResult.releases) {
const release = await fetchReleaseMetadata(releaseSummary.metadataUrl, env)
updateLatestReleaseByProduct(latestReleaseByProduct, release)
if (release.releaseKind === "ota") {
if (!releaseSummary.archiveUrl) {
@ -96,6 +106,15 @@ async function runSyncGitHubReleases(env: Env) {
await putLatestPolicyRecord(env.OTA_KV, release)
}
for (const [product, latestRelease] of latestReleaseByProduct) {
await putLatestReleaseVersionRecord(env.OTA_KV, {
product,
version: latestRelease.releaseVersion,
publishedAt: latestRelease.publishedAt,
tag: latestRelease.tag,
})
}
if (releasesResult.etag) {
await env.OTA_KV.put(KV_KEYS.githubEtag, releasesResult.etag)
}
@ -162,6 +181,23 @@ async function runSyncStoreVersions(env: Env) {
await env.OTA_KV.put(KV_KEYS.storeVersionSyncLastSuccessAt, fetchedAt)
}
function updateLatestReleaseByProduct(
latestReleaseByProduct: Map<
OtaRelease["product"],
Pick<OtaRelease, "releaseVersion" | "publishedAt"> & { tag: string }
>,
release: OtaRelease,
) {
const current = latestReleaseByProduct.get(release.product)
if (!current || compareSemver(release.releaseVersion, current.releaseVersion) > 0) {
latestReleaseByProduct.set(release.product, {
releaseVersion: release.releaseVersion,
publishedAt: release.publishedAt,
tag: release.git.tag,
})
}
}
export async function mirrorReleaseToStorage(
input: {
release: OtaRelease

View File

@ -0,0 +1,67 @@
import { Hono } from "hono"
import type { Env } from "../env"
import { getLatestReleaseVersionRecord, getStoreVersionRecord } from "../lib/kv"
import { STORE_URLS } from "../lib/store-version"
export const versionsRoute = new Hono<{ Bindings: Env }>()
versionsRoute.get("/versions", async (c) => {
const [ios, android, mas, mss, mobileRelease, desktopRelease] = await Promise.all([
getStoreVersionRecord(c.env.OTA_KV, { product: "mobile", target: "ios" }),
getStoreVersionRecord(c.env.OTA_KV, { product: "mobile", target: "android" }),
getStoreVersionRecord(c.env.OTA_KV, { product: "desktop", target: "mas" }),
getStoreVersionRecord(c.env.OTA_KV, { product: "desktop", target: "mss" }),
getLatestReleaseVersionRecord(c.env.OTA_KV, "mobile"),
getLatestReleaseVersionRecord(c.env.OTA_KV, "desktop"),
])
return c.json({
store: {
mobile: {
ios: toStoreVersionPayload(ios, STORE_URLS.ios),
android: toStoreVersionPayload(android, STORE_URLS.android),
},
desktop: {
mas: toStoreVersionPayload(mas, STORE_URLS.mas),
mss: toStoreVersionPayload(mss, STORE_URLS.mss),
},
},
github: {
mobile: toLatestReleasePayload(c.env, mobileRelease),
desktop: toLatestReleasePayload(c.env, desktopRelease),
},
})
})
function toStoreVersionPayload(
record: Awaited<ReturnType<typeof getStoreVersionRecord>>,
url: string,
) {
if (!record) {
return null
}
return {
version: record.version,
fetchedAt: record.fetchedAt,
source: record.source,
url,
}
}
function toLatestReleasePayload(
env: Env,
record: Awaited<ReturnType<typeof getLatestReleaseVersionRecord>>,
) {
if (!record) {
return null
}
return {
version: record.version,
publishedAt: record.publishedAt,
tag: record.tag,
url: `https://github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}/releases/tag/${record.tag}`,
}
}