diff --git a/src/main/runtime/mobile-android-release-feed.test.ts b/src/main/runtime/mobile-android-release-feed.test.ts index 1168c7efffe..85e9e8684f2 100644 --- a/src/main/runtime/mobile-android-release-feed.test.ts +++ b/src/main/runtime/mobile-android-release-feed.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { __resetAndroidReleaseFeedCacheForTests, __setAndroidReleaseFeedFetcherForTests, - extractLatestAndroidVersion, + extractAndroidVersionCandidates, getRecommendedAndroidVersion, isAndroidReleaseFeedRefreshPending } from './mobile-android-release-feed' @@ -12,47 +12,40 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('extractLatestAndroidVersion', () => { - it('picks the newest stable mobile-android tag', () => { - const version = extractLatestAndroidVersion([ - { tag_name: 'mobile-android-v0.0.30' }, - { tag_name: 'mobile-android-v0.0.31' }, - { tag_name: 'mobile-android-v0.0.9' }, - { tag_name: 'v1.4.150' }, // desktop release — ignored - { tag_name: 'mobile-ios-v0.0.31' } // iOS is never sourced here +describe('extractAndroidVersionCandidates', () => { + it('sorts exact mobile-android tag refs newest first', () => { + const versions = extractAndroidVersionCandidates([ + { ref: 'refs/tags/mobile-android-v0.0.30' }, + { ref: 'refs/tags/mobile-android-v0.0.31' }, + { ref: 'refs/tags/mobile-android-v0.0.9' }, + { ref: 'refs/tags/v1.4.150' }, // desktop release — ignored + { ref: 'refs/tags/mobile-ios-v0.0.31' } // iOS is never sourced here ]) // Why: numeric compare, so 0.0.31 beats 0.0.9 (string compare would invert). - expect(version).toBe('0.0.31') + expect(versions).toEqual(['0.0.31', '0.0.30', '0.0.9']) }) - it('accepts the published mobile channel while skipping drafts', () => { - const version = extractLatestAndroidVersion([ - { tag_name: 'mobile-android-v0.0.40', prerelease: true }, - { tag_name: 'mobile-android-v0.0.41', draft: true }, - { tag_name: 'mobile-android-v0.0.31' } - ]) - // Android workflow releases use --prerelease to avoid replacing desktop latest. - expect(version).toBe('0.0.40') - }) - - it('rejects non-semver and malformed tags, failing open to null', () => { + it('rejects non-semver and malformed refs', () => { expect( - extractLatestAndroidVersion([ - { tag_name: 'mobile-android-vlatest' }, - { tag_name: 'mobile-android-v0.0' }, - { tag_name: 42 }, + extractAndroidVersionCandidates([ + { ref: 'refs/tags/mobile-android-vlatest' }, + { ref: 'refs/tags/mobile-android-v0.0' }, + { ref: 42 }, {} ]) - ).toBeNull() - expect(extractLatestAndroidVersion([])).toBeNull() + ).toEqual([]) }) - it('deduplicates a cold refresh and exposes its completion without blocking callers', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify([{ tag_name: 'mobile-android-v0.0.40', prerelease: true }]), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) + it('deduplicates a cold refresh and verifies the published APK', async () => { + const fetchMock = vi.fn(async (url: string) => + url.includes('/git/matching-refs/') + ? jsonResponse([{ ref: 'refs/tags/mobile-android-v0.0.40' }]) + : jsonResponse({ + tag_name: 'mobile-android-v0.0.40', + draft: false, + prerelease: true, + assets: [{ name: 'app-release.apk' }] + }) ) __setAndroidReleaseFeedFetcherForTests(fetchMock) @@ -65,6 +58,56 @@ describe('extractLatestAndroidVersion', () => { expect(isAndroidReleaseFeedRefreshPending()).toBe(false) }) expect(getRecommendedAndroidVersion(1_000)).toBe('0.0.40') - expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('falls back when newer tags lack a confirmed published APK', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/git/matching-refs/')) { + return jsonResponse([ + { ref: 'refs/tags/mobile-android-v0.0.42' }, + { ref: 'refs/tags/mobile-android-v0.0.41' }, + { ref: 'refs/tags/mobile-android-v0.0.40' } + ]) + } + const version = url.match(/mobile-android-v(\d+\.\d+\.\d+)$/)?.[1] ?? '' + return jsonResponse({ + tag_name: `mobile-android-v${version}`, + draft: version === '0.0.41' ? 'false' : false, + assets: version === '0.0.42' ? [] : [{ name: 'app-release.apk' }] + }) + }) + __setAndroidReleaseFeedFetcherForTests(fetchMock) + + expect(getRecommendedAndroidVersion(1_000)).toBeNull() + await vi.waitFor(() => expect(isAndroidReleaseFeedRefreshPending()).toBe(false)) + + expect(getRecommendedAndroidVersion(1_000)).toBe('0.0.40') + expect(fetchMock).toHaveBeenCalledTimes(4) + }) + + it('fails open without probing more tags after a provider failure', async () => { + const fetchMock = vi.fn(async (url: string) => + url.includes('/git/matching-refs/') + ? jsonResponse([ + { ref: 'refs/tags/mobile-android-v0.0.41' }, + { ref: 'refs/tags/mobile-android-v0.0.40' } + ]) + : jsonResponse({ message: 'rate limited' }, 403) + ) + __setAndroidReleaseFeedFetcherForTests(fetchMock) + + expect(getRecommendedAndroidVersion(1_000)).toBeNull() + await vi.waitFor(() => expect(isAndroidReleaseFeedRefreshPending()).toBe(false)) + + expect(getRecommendedAndroidVersion(1_000)).toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(2) }) }) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' } + }) +} diff --git a/src/main/runtime/mobile-android-release-feed.ts b/src/main/runtime/mobile-android-release-feed.ts index fe6569d51f7..4a52ac2a79f 100644 --- a/src/main/runtime/mobile-android-release-feed.ts +++ b/src/main/runtime/mobile-android-release-feed.ts @@ -1,24 +1,30 @@ import { net } from 'electron' import { compareVersions, isValidVersion } from '../updater-fallback' -// Why: the Android app is distributed as an APK from GitHub Releases, so the -// release list IS the source of truth for "latest Android build" — never a -// hand-maintained constant. iOS is deliberately absent: it ships through the +// Why: published GitHub releases with APK assets are the Android source of truth, +// never a hand-maintained constant. iOS is deliberately absent: it ships through the // App Store, which auto-updates, and the hard minimum-version cutoff is handled // separately by ProtocolBlockScreen, so no soft iOS nudge is derived here. -const RELEASES_API_URL = 'https://api.github.com/repos/stablyai/orca/releases?per_page=100' +const GITHUB_API_ROOT = 'https://api.github.com/repos/stablyai/orca' +const ANDROID_TAG_REFS_URL = `${GITHUB_API_ROOT}/git/matching-refs/tags/mobile-android-v` const FETCH_TIMEOUT_MS = 5000 // Why: a mobile build ships ~monthly, so a long TTL keeps this to a few // unauthenticated GitHub calls per day per host while staying fresh enough. const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // Why: only exact Android release tags count, so suffixed RC builds never nudge stable users. -const ANDROID_TAG_RE = /^mobile-android-v(\d+\.\d+\.\d+)$/ +const ANDROID_TAG_REF_RE = /^refs\/tags\/mobile-android-v(\d+\.\d+\.\d+)$/ +// Why: malformed/orphaned tags must not turn one refresh into unbounded API fanout. +const MAX_RELEASE_CANDIDATES = 3 -type ReleaseFeedEntry = { +type AndroidTagRef = { + ref?: unknown +} + +type AndroidRelease = { tag_name?: unknown draft?: unknown - prerelease?: unknown + assets?: unknown } type CacheState = { @@ -26,54 +32,92 @@ type CacheState = { fetchedAt: number } -type ReleaseFeedResponse = Pick -type ReleaseFeedFetcher = () => Promise +type GitHubResponse = Pick +type GitHubFetcher = (url: string, signal: AbortSignal) => Promise -const defaultReleaseFeedFetcher: ReleaseFeedFetcher = () => - net.fetch(RELEASES_API_URL, { - headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'orca-runtime' }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) +const defaultGitHubFetcher: GitHubFetcher = (url, signal) => + net.fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'orca-runtime', + 'X-GitHub-Api-Version': '2022-11-28' + }, + signal }) // Module-level cache shared across every status.get on this host. let cache: CacheState | null = null let inFlight: Promise | null = null -let releaseFeedFetcher = defaultReleaseFeedFetcher +let githubFetcher = defaultGitHubFetcher -export function extractLatestAndroidVersion(entries: ReleaseFeedEntry[]): string | null { - let latest: string | null = null +export function extractAndroidVersionCandidates(entries: AndroidTagRef[]): string[] { + const versions = new Set() for (const entry of entries) { - // Mobile releases intentionally use GitHub's prerelease flag so they do not - // replace the desktop's latest release; the strict tag defines this channel. - if (entry.draft === true) { + if (typeof entry.ref !== 'string') { continue } - if (typeof entry.tag_name !== 'string') { - continue - } - const match = entry.tag_name.match(ANDROID_TAG_RE) + const match = entry.ref.match(ANDROID_TAG_REF_RE) const version = match?.[1] if (!version || !isValidVersion(version)) { continue } - if (latest === null || compareVersions(version, latest) > 0) { - latest = version - } + versions.add(version) } - return latest + return [...versions].sort((left, right) => compareVersions(right, left)) +} + +function releaseHasAndroidApk(release: AndroidRelease, version: string): boolean { + if (release.draft !== false || release.tag_name !== `mobile-android-v${version}`) { + return false + } + if (!Array.isArray(release.assets)) { + return false + } + return release.assets.some((asset) => { + if (typeof asset !== 'object' || asset === null) { + return false + } + const name = (asset as { name?: unknown }).name + return typeof name === 'string' && name.toLowerCase().endsWith('.apk') + }) } async function fetchLatestAndroidVersion(): Promise { + const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS) try { - const response = await releaseFeedFetcher() - if (!response.ok) { + const refsResponse = await githubFetcher(ANDROID_TAG_REFS_URL, signal) + if (!refsResponse.ok) { return null } - const body = (await response.json()) as unknown - if (!Array.isArray(body)) { + const refsBody = (await refsResponse.json()) as unknown + if (!Array.isArray(refsBody)) { return null } - return extractLatestAndroidVersion(body as ReleaseFeedEntry[]) + const candidates = extractAndroidVersionCandidates(refsBody as AndroidTagRef[]).slice( + 0, + MAX_RELEASE_CANDIDATES + ) + for (const version of candidates) { + const tag = `mobile-android-v${version}` + const releaseUrl = `${GITHUB_API_ROOT}/releases/tags/${encodeURIComponent(tag)}` + const releaseResponse = await githubFetcher(releaseUrl, signal) + if (!releaseResponse.ok) { + // A tag can briefly precede its release; other failures should not fan out requests. + if (releaseResponse.status === 404) { + continue + } + return null + } + const release = (await releaseResponse.json()) as unknown + if ( + typeof release === 'object' && + release !== null && + releaseHasAndroidApk(release as AndroidRelease, version) + ) { + return version + } + } + return null } catch { // Why: fail open — an unreachable/rate-limited/offline host simply advertises // no recommendation, and mobile shows no banner. @@ -119,7 +163,7 @@ export function isAndroidReleaseFeedRefreshPending(): boolean { export function __resetAndroidReleaseFeedCacheForTests(): void { cache = null inFlight = null - releaseFeedFetcher = defaultReleaseFeedFetcher + githubFetcher = defaultGitHubFetcher } // Test-only: seed the cache with a fresh value so status.get reads it without @@ -129,6 +173,6 @@ export function __setAndroidReleaseFeedCacheForTests(version: string | null): vo inFlight = null } -export function __setAndroidReleaseFeedFetcherForTests(fetcher: ReleaseFeedFetcher): void { - releaseFeedFetcher = fetcher +export function __setAndroidReleaseFeedFetcherForTests(fetcher: GitHubFetcher): void { + githubFetcher = fetcher } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d16743aa483..aba13dcb666 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3397,7 +3397,7 @@ export class OrcaRuntimeService { // omit rather than fabricate when running outside that bootstrap (tests). appVersion: process.env.ORCA_APP_VERSION, // Why: derived live from GitHub Releases (cached, fail-open) instead of a - // hand-bumped constant, so the Android nudge can never go stale; omitted + // hand-bumped constant, so releases need no recommendation bookkeeping; omitted // entirely when unknown. iOS defers to the App Store (no soft nudge). recommendedMobileAppVersions: mobileRecommendation.versions, recommendedMobileAppVersionsPending: mobileRecommendation.pending,