mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
fix(mobile): remove Android release page cutoff
This commit is contained in:
@@ -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' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Response, 'ok' | 'json'>
|
||||
type ReleaseFeedFetcher = () => Promise<ReleaseFeedResponse>
|
||||
type GitHubResponse = Pick<Response, 'ok' | 'status' | 'json'>
|
||||
type GitHubFetcher = (url: string, signal: AbortSignal) => Promise<GitHubResponse>
|
||||
|
||||
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<void> | 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<string>()
|
||||
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<string | null> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user